@baliola/smart-account-sdk
v0.3.1
Published
Client-side SDK for Baliola's Smart Account (ERC-4337) stack on MAC
Maintainers
Readme
@baliola/smart-account-sdk
Client-side TypeScript SDK for Baliola's ERC-4337 v0.7 stack on MAC chain. A thin viem extension that handles UserOperation packing, signing, bundler RPC, and Gas Allowance Pool (paymaster) wiring. Pick a chain by name, plug in your API key, send transactions.
const account = await toSmartAccount({ owner, chain: "macTestnet" });
const client = await createSmartAccountClient({
account,
chain: "macTestnet",
apiKey: BALIOLA_KEY,
});
const userOpHash = await client.writeContract({
address: nft,
abi: nftAbi,
functionName: "mint",
args: [1n],
});
const receipt = await client.waitForUserOperationReceipt({ userOpHash });Install
bun add @baliola/smart-account-sdk viem
# or: npm install / pnpm addviem (^2.46) is a peer dependency. Install it alongside.
Quick Start
import { privateKeyToAccount } from "viem/accounts";
import {
createSmartAccountClient,
toSmartAccount,
} from "@baliola/smart-account-sdk";
const owner = privateKeyToAccount(process.env.OWNER_KEY as `0x${string}`);
const account = await toSmartAccount({
owner,
chain: "macTestnet",
});
// createSmartAccountClient is async. It validates the API key against
// baliola-auth before returning.
const client = await createSmartAccountClient({
account,
chain: "macTestnet",
apiKey: process.env.BALIOLA_KEY!,
});
const userOpHash = await client.writeContract({
address: "0xYourContract",
abi: yourAbi,
functionName: "doSomething",
});
const receipt = await client.waitForUserOperationReceipt({ userOpHash });
console.log("included in tx:", receipt.txHash);Chains
The SDK is opinionated about MAC. Pick a chain by name and everything else is preconfigured: chain RPC, bundler RPC, and the auth service URL.
| chain | Network |
|---|---|
| "macTestnet" | MAC Testnet |
| "macMainnet" | MAC Mainnet |
import { macTestnet, macMainnet, resolveChain, MAC_CHAINS } from "@baliola/smart-account-sdk";
resolveChain("macTestnet"); // viem Chain object
Object.keys(MAC_CHAINS); // ["macTestnet", "macMainnet"]API Key Validation
createSmartAccountClient validates the API key once at construction time. On success, the key info (including quota) is exposed on client.apiKey. On rejection it throws ApiKeyInvalidError; on transport failure it throws BaliolaAuthUnreachableError.
try {
const client = await createSmartAccountClient({
account,
chain: "macTestnet",
apiKey: BALIOLA_KEY,
});
client.apiKey.id; // apiKeyId (uuid)
client.apiKey.accountId; // baliola account uuid
client.apiKey.module; // "smart-account"
client.apiKey.quota.callsRemaining; // remaining calls in the period
client.apiKey.quota.periodResetsAt; // ISO 8601 timestamp
} catch (err) {
if (err instanceof ApiKeyInvalidError) {
switch (err.reason) {
case "revoked":
case "expired":
case "not_found":
case "wrong_module":
case "module_inactive":
case "origin_not_allowed":
// bad key; user needs to fix it
break;
case "quota_exceeded":
case "rate_limited":
// back off using err.retryAfterSeconds
break;
}
} else if (err instanceof BaliolaAuthUnreachableError) {
// baliola-auth down or network issue; safe to retry
}
}Each construction consumes one quota call. Cache the client; don't rebuild it on every request.
To point at a custom auth host (for staging or dev), pass authUrl:
await createSmartAccountClient({
account,
chain: "macTestnet",
apiKey: BALIOLA_KEY,
authUrl: "https://your-auth-host",
});createSmartAccountClient options
await createSmartAccountClient({
account, // required, from toSmartAccount
chain, // required, "macTestnet" | "macMainnet"
apiKey, // required
authUrl?, // override the chain's default auth host
paymasterAddress?, // override the chain's default Gas Allowance Pool
bundlerUrl?, // override the default bundler URL
bundlerTransport?, // full viem Transport escape hatch
transport?, // chain RPC transport (defaults to chain's built-in RPC)
});Resolution precedence:
- Auth URL:
authUrlthen the default for the chain. - Chain RPC:
transportthenhttp()of the chain default. - Bundler RPC:
bundlerTransportthenbundlerUrlthen the chain default. - Paymaster:
paymasterAddressthen the chain's registered Gas Allowance Pool, otherwise throwPaymasterConfigError.
toSmartAccount options
toSmartAccount({
owner, // required, viem Account or "0x"-prefixed 32-byte private key
chain, // required, "macTestnet" | "macMainnet"
salt?, // CREATE2 salt for counterfactual address (default 0n)
nonceKey?, // EntryPoint nonce key (default 0n)
transport?, // chain RPC transport (defaults to chain's built-in RPC)
publicClient?, // share a publicClient across calls (default: built internally)
});Returned ISmartAccount is counterfactual until the first UserOperation is sent. The factory call is injected automatically.
writeContract, the single write verb
Same shape for everything: a typed call, a raw call, or a batch.
Typed contract call
const userOpHash = await client.writeContract({
address: counter,
abi: counterAbi,
functionName: "increment",
args: [],
value: 0n, // optional
});Raw call (ETH transfer or pre-encoded data)
// Pure native transfer
await client.writeContract({ to: recipient, value: 1n });
// Pre-encoded calldata
await client.writeContract({ to: target, value: 0n, data: "0xabcd..." });Batch with an array
Mixed typed and raw entries are allowed in one batch. The whole batch is one UserOperation submitted via SimpleAccount.executeBatch.
const userOpHash = await client.writeContract([
{ address: usdc, abi: erc20Abi, functionName: "approve", args: [router, amount] },
{ address: router, abi: routerAbi, functionName: "swap", args: [...] },
{ to: refundAddr, value: 1n }, // raw entry alongside typed ones
]);Overrides
A second optional argument lets you override UserOperation-level fields (gas, fee, paymaster, nonce). Most callers never touch this.
await client.writeContract(input, {
preVerificationGas: 200_000n,
maxFeePerGas: 5_000_000_000n,
});Receipts
// One-shot; returns null if not yet included
const maybe = await client.getUserOperationReceipt({ userOpHash });
// Poll until included or timeout
const receipt = await client.waitForUserOperationReceipt({
userOpHash,
timeout: 60_000,
pollingInterval: 1_000,
signal: abortSignal, // optional AbortSignal
});
receipt.userOpHash; // ERC-4337 hash
receipt.txHash; // on-chain handleOps tx hash
receipt.blockNumber;
receipt.success;
receipt.actualGasUsed;
receipt.actualGasCost;
receipt.logs; // pre-sliced to this UserOpWatching events
Subscribe to EntryPoint.UserOperationEvent filtered by sender or a specific userOpHash. The SDK decodes each match into typed fields.
const unwatch = client.watchUserOperations({
// Defaults to your account's address when omitted
sender: account.address,
onUserOp(event) {
console.log(event.userOpHash, event.success, event.actualGasCost);
},
onError(err) { console.error(err); },
});
unwatch(); // stop the subscriptionPaymaster (Gas Allowance Pool)
Every UserOperation is sponsored by a paymaster. The default is the Gas Allowance Pool (GAP) registered for the chain, no configuration required.
// Default: uses the chain's GAP
await createSmartAccountClient({ account, chain: "macTestnet", apiKey });
// Override: route through a different paymaster contract
await createSmartAccountClient({
account, chain: "macTestnet", apiKey,
paymasterAddress: "0x...",
});paymasterAddress is validated at construction. Invalid or zero addresses throw PaymasterConfigError. Self-funded mode is not supported.
Errors
Every SDK error extends SmartAccountSDKError. The library is opinionated about messages: err.message is always written for an end user (short, polite, and safe to surface directly in product UI). Technical detail lives on:
err.code: internal tag or AA code (e.g."API_KEY_INVALID","AA24")err.reason: typed enum on errors that have one (ApiKeyInvalidError,PaymasterConfigError)err.detail: optional technical sentence for developer logserr.cause: the underlying transport or library error
Discriminate with instanceof, not message matching.
SmartAccountSDKError // root
├─ UserOperationReceiptTimeoutError
├─ PaymasterConfigError // dev-config error, carries `reason`
├─ ApiKeyInvalidError // baliola-auth rejected the key (carries `reason`)
├─ BaliolaAuthUnreachableError // network or 5xx talking to baliola-auth
├─ BundlerRpcError
│ ├─ InvalidUserOperationError // bundler rejected the params
│ ├─ UserOperationRejectedError // bundler dropped on submit
│ └─ BundlerUnreachableError // network or timeout
└─ UserOperationRevertError // AA-code reverts
├─ AccountFactoryError // AA1x
├─ AccountValidationError // AA2x: signature, nonce, prefund
├─ PaymasterValidationError // AA3x: paymaster rejection
└─ BundleFrameError // AA9xSurfacing errors
You can pipe err.message straight into a toast or banner. It's already user-safe:
try {
await client.writeContract({ /* ... */ });
} catch (err) {
toast(err instanceof Error ? err.message : "Something went wrong.");
console.error(err); // full technical detail still on err.detail / err.cause
}Default user messages
| Error | err.message |
|---|---|
| ApiKeyInvalidError (reason=revoked) | "This API key has been revoked. Please generate a new one." |
| ApiKeyInvalidError (reason=expired) | "This API key has expired. Please generate a new one." |
| ApiKeyInvalidError (reason=quota_exceeded) | "You've hit the request limit. Please try again later." |
| ApiKeyInvalidError (reason=rate_limited) | "Too many requests. Please slow down and try again." |
| ApiKeyInvalidError (reason=not_found) | "Invalid API key. Please check your credentials." |
| BaliolaAuthUnreachableError | "Authentication service is temporarily unavailable. Please try again in a moment." |
| BundlerUnreachableError | "Couldn't reach the network. Please check your connection and try again." |
| UserOperationReceiptTimeoutError | "Your transaction is taking longer than expected. Please check the block explorer or try again." |
| PaymasterValidationError (AA3x) | "Gas sponsorship was declined for this transaction. Please try again later." |
| AccountValidationError (AA2x) | "Your transaction couldn't be authorized. Please try again." |
| AccountFactoryError (AA1x) | "We couldn't set up your smart account. Please try again." |
| BundleFrameError (AA9x) | "Something went wrong submitting your transaction. Please try again." |
| InvalidUserOperationError | "Your transaction was rejected before submission. Please try again." |
| UserOperationRejectedError | "Your transaction was rejected by the network. Please try again." |
| PaymasterConfigError | "Gas sponsorship isn't available for this network. Please contact support." |
Tailoring the copy
If you need different tone, branding, or i18n, switch on the typed fields and write your own copy. Every error carries enough metadata to do so:
function copy(err: unknown): string {
if (err instanceof ApiKeyInvalidError) {
switch (err.reason) {
case "quota_exceeded":
return err.retryAfterSeconds
? `Limit hit, try again in ${err.retryAfterSeconds}s.`
: "Daily limit reached.";
case "revoked": return "Your access has been revoked.";
// ...
}
}
if (err instanceof Error) return err.message; // sensible default
return "Something went wrong.";
}Subpath imports
import { createSmartAccountClient } from "@baliola/smart-account-sdk";
import { toSmartAccount } from "@baliola/smart-account-sdk/accounts";
import { writeContract } from "@baliola/smart-account-sdk/actions";
import { validateApiKey, DEFAULT_AUTH_URLS } from "@baliola/smart-account-sdk/auth";
import { macTestnet, MAC_CHAINS } from "@baliola/smart-account-sdk/chains";
import { DEFAULT_BUNDLER_URLS } from "@baliola/smart-account-sdk/clients";
import { SmartAccountSDKError } from "@baliola/smart-account-sdk/errors";
import type { UserOperationReceipt } from "@baliola/smart-account-sdk/types";License
Proprietary. Copyright 2026 Baliola. All rights reserved. See LICENSE.
