Skip to content

Chapter 3: Smart Account Infrastructure

This chapter defines the employee-side infrastructure: how individual smart accounts work, how they are created, how session keys authorize automated actions, and how recovery protects against key loss.

Openfort V1 ERC-4337 smart accounts (BaseOpenfortAccount.sol). These are the contracts in the openfort-xyz/openfort-contracts repository (GPL-3.0). Key properties:

  • ERC-4337 compliant (works with any bundler and paymaster)
  • Upgradeable via proxy pattern (ERC-1967)
  • Built-in session keys with contract-level whitelisting
  • Built-in guardian system (Argent-inspired social recovery)
  • Batch transactions (up to 9 calls per batch via executeBatch)
  • Counterfactual deployment (address computed via CREATE2 before deployment)

This distinction matters because conflating them is the most common source of architectural confusion.

Organization Safe (Treasury). A Safe multisig controlled by the organization’s signers. Holds company funds. Capxul modules are installed onto this Safe. The WaaS provider evaluation has nothing to do with this account. See Chapter 2.

Individual smart account (Employee/Recipient). A per-user ERC-4337 smart account. Not a Safe. Belongs to the individual. It is the permanent destination for all payments from any organization that pays this user through Capxul. The user controls it via an embedded wallet (EOA signer) managed by Openfort.

Openfort V1 smart accounts are NOT ERC-7579 compliant. They have their own module system with built-in session keys, guardians, social recovery, and batch transactions. These are Openfort-proprietary patterns, not 7579 modules.

This means the Rhinestone module ecosystem (ZK Email Recovery, Deadman Switch, ColdStorage, Scheduled Transfers) does not install on Openfort accounts.

This does not block Capxul because Openfort’s built-in features cover everything needed:

FeatureNeeded?Openfort native?
Session keys (scoped, time-bound)YesYes
Gas sponsorshipYesYes (or BYO paymaster)
Social recovery (guardians)YesYes (Argent-inspired)
Counterfactual deployment (CREATE2)YesYes
Batch transactionsYesYes
EIP-7702 (upgrade EOA to smart account)RoadmapYes (V2 delegator)
Upgradeable accounts (proxy pattern)YesYes (ERC-1967)

The accounts use upgradeable proxies, so if Openfort or the community adds 7579 support in a future implementation, existing accounts can upgrade without changing addresses.

Better Auth (magic links) + Openfort. The @openfort/better-auth plugin bridges the two systems.

Better Auth handles user authentication (magic links, sessions, JWTs). Openfort handles wallet creation and management. The plugin adds one endpoint to the Better Auth server: /api/auth/encryption-session.

auth.ts
import { betterAuth } from "better-auth";
import { bearer } from "better-auth/plugins";
import { openfort, encryptionSession } from "@openfort/better-auth";
import Openfort from "@openfort/openfort-node";
const openfortSDK = new Openfort(process.env.OPENFORT_SECRET_KEY);
export const auth = betterAuth({
database: {
// Better Auth needs SQL-compatible storage for its tables.
// Convex is document-based and does not work here.
// Use SQLite (dev) or Turso/Postgres (prod).
},
plugins: [
bearer(),
openfort({
client: openfortSDK,
use: [
encryptionSession({
config: {
apiKey: process.env.SHIELD_PUBLISHABLE_KEY,
secretKey: process.env.SHIELD_SECRET_KEY,
encryptionPart: process.env.SHIELD_ENCRYPTION_SHARE,
},
}),
],
}),
],
});
providers.tsx
import { OpenfortProvider, ThirdPartyOAuthProvider } from "@openfort/react";
import { OpenfortWagmiBridge, getDefaultConfig } from "@openfort/react/wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider, createConfig } from "wagmi";
import { base, baseSepolia } from "viem/chains";
const wagmiConfig = createConfig(
getDefaultConfig({ appName: "Capxul", chains: [base, baseSepolia] })
);
export function Providers({ children }) {
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={new QueryClient()}>
<OpenfortWagmiBridge>
<OpenfortProvider
publishableKey={OPENFORT_PUBLISHABLE_KEY}
walletConfig={{
shieldPublishableKey: SHIELD_PUBLISHABLE_KEY,
ethereum: {
ethereumFeeSponsorshipId: FEE_SPONSORSHIP_ID,
},
getEncryptionSession: async ({ accessToken }) => {
const res = await fetch(
BETTERAUTH_URL + "/api/auth/encryption-session",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
}
);
if (!res.ok) return null;
const data = await res.json();
return data.sessionId;
},
connectOnLogin: false,
}}
thirdPartyAuth={{
getAccessToken: async () => {
const session = await authClient.getSession();
return session?.data?.session?.token ?? null;
},
provider: ThirdPartyOAuthProvider.BETTER_AUTH,
}}
>
{children}
</OpenfortProvider>
</OpenfortWagmiBridge>
</QueryClientProvider>
</WagmiProvider>
);
}
  1. User enters their email in the Capxul app
  2. Better Auth sends a magic link code to their email
  3. User enters the code
  4. Better Auth validates, creates a session, issues a Bearer token
  5. Openfort SDK (client-side) calls getAccessToken(), gets the Better Auth token
  6. Openfort SDK calls getEncryptionSession(), which hits /api/auth/encryption-session
  7. Better Auth server (with Openfort plugin) validates the token, calls Shield, returns an encryption session ID
  8. OpenSigner (browser iframe) uses that session ID to either:
    • New user: Generate a private key, split into 3 shares, distribute to device/hot/Shield
    • Returning user: Retrieve shares, reconstruct key
  9. User now has a live embedded wallet (EOA signer) controlling their smart account

Better Auth needs SQL-compatible storage for its session and user tables. Convex is a document database without SQL compatibility. Better Auth runs on a separate Hono or Express server with SQLite (dev) or Turso/Postgres (production). This server handles auth only. All other backend logic (user profiles, org data, routing rules, stream caching, transaction history) lives in Convex. The two systems share identity via email or user ID.

When a user creates a wallet, the private key is generated client-side in the user’s browser, inside an isolated iframe sandbox. The key never touches any server in its complete form.

Immediately after generation, the key is split into three shares using Shamir’s Secret Sharing:

  • Share 1 (Device share). Stored on the user’s device in the iframe’s isolated storage. Never leaves the browser.
  • Share 2 (Hot share). Sent encrypted to a server. Openfort’s cloud or Capxul’s self-hosted instance.
  • Share 3 (Recovery share). Sent encrypted to the Shield server. Openfort’s cloud or Capxul’s self-hosted instance.

Any 2 of 3 shares reconstruct the full private key. The complete key only exists temporarily in memory during signing operations, then is discarded.

Privy (Capxul’s previous provider, acquired by Stripe) uses a 2-of-2 TEE (Trusted Execution Environment) model. The key is generated inside a TEE and split into an enclave share and an auth share. Both are required to sign. The TEE infrastructure is proprietary and non-exportable.

OpenSigner uses 2-of-3 Shamir shares in standard compute environments (no specialized TEE hardware). The tradeoff: TEEs provide hardware-level isolation guarantees that software-only approaches don’t. OpenSigner compensates with iframe sandboxing, share separation across distinct security boundaries, and full auditability.

Self-hosting means running two services:

  1. Hot share server. Stores encrypted hot shares. Simple key-value store with auth validation.
  2. Shield server. Stores encrypted recovery shares. Issues encryption sessions when users authenticate.

Both are available at github.com/openfort-xyz/opensigner. Deploy via Docker. Point the SDK configuration at your servers instead of Openfort’s cloud. User wallets continue working because the encryption scheme is independent of who hosts the server.

Based on direct source code analysis of BaseOpenfortAccount.sol. This section is the authoritative reference for session key capabilities and limitations across the entire architecture.

  • Time bounds. validAfter and validUntil timestamps
  • Transaction count limit. limit field (uint48, decremented on each use)
  • Contract whitelist. Up to 10 contract addresses. The session key can only call execute() or executeBatch() targeting these contracts.
  • Anti-reentrancy. Session keys cannot call back into the smart account itself.
  • Owner binding. Session keys are invalidated if account ownership transfers.
  • Function selector restrictions. The session key can call any function on a whitelisted contract, not just specific functions.
  • Parameter-level constraints. The whitelist checks the target contract address of the execute() call, not the parameters of the function being called on that contract. For ERC-20 transfers, the whitelisted address is the USDC contract, not the recipient of the transfer.
  • Spending limits per token. The limit field tracks transaction count, not value.
  • Hook/validator extensibility. The V1 contracts have no hook mechanism for custom validation logic. (The hook mechanism described in Openfort’s blog posts is from the V2 7702 Delegator, not V1.)

The Capxul backend holds a session key per employee smart account. The session key is registered with a whitelist of:

  • The USDC contract address (for routing transfers)
  • The LlamaPay contract address (for automated claiming, if enabled)

A transaction count limit is set (e.g., 100 transactions) with a 6-month expiry. The employee grants this during initial routing setup via an owner-signed transaction.

Because V1 session keys cannot enforce destination addresses on-chain (they whitelist contracts, not function parameters), Capxul uses a two-path strategy:

Path A (Launch): Offchain Enforcement in Convex

Section titled “Path A (Launch): Offchain Enforcement in Convex”

Before the Convex backend constructs and signs a UserOp with the session key, it checks:

  • Is the destination address in the employee’s registered destinations (EOA addresses, provider deposit addresses, bridge contract addresses)?
  • Is the amount within the employee’s configured per-transaction limit?
  • Is the routing rule valid (not paused, not expired)?

If any check fails, the backend does not sign. No UserOp is constructed. No transaction goes on-chain.

The on-chain session key provides a coarser safety net: the transaction can only interact with USDC and LlamaPay, has a count limit, and expires.

Risk profile (Path A). If the Convex backend is compromised, an attacker with the session key can send USDC from the employee’s smart account to any address, up to the transaction count limit. The per-transaction value is unbounded on-chain (limited only by the smart account’s balance). Mitigation: keep transaction count limits low, implement monitoring and alerting in Convex that flags transactions to unregistered destinations, auto-revoke session keys on anomalous activity.

Path B (Fast Follow): On-Chain Enforcement via CapxulRouter

Section titled “Path B (Fast Follow): On-Chain Enforcement via CapxulRouter”

A singleton smart contract (deployed behind an upgradeable proxy) that enforces destination whitelisting on-chain:

  • The session key whitelists only the CapxulRouter contract address (and LlamaPay).
  • The router maintains an on-chain allowlist per smart account: mapping(address account => mapping(address destination => bool allowed)).
  • When routing, the backend uses the session key to call execute(ROUTER_ADDRESS, 0, routeTransfer(token, to, amount)).
  • The router checks: is to in this account’s allowlist? If yes, executes transferFrom (using a prior ERC-20 approval). If no, reverts.
  • The allowlist is managed by Capxul’s admin key (separate from session keys).

Risk profile (Path B). An attacker needs both the session key AND Capxul’s admin key to steal funds. The session key alone can only send to allowlisted destinations (the employee’s own wallets and provider deposit/bridge addresses). The admin key alone can add destinations but cannot move funds.

When the total value held across all employee smart accounts exceeds a risk threshold determined by the team (suggested: $100,000 aggregate or $5,000 per individual account), the CapxulRouter should be deployed and session keys migrated. The architecture supports both paths with the same Convex routing logic; only the execution mechanism changes.

Routing rules live entirely in Convex. The session key is the execution mechanism. These are separate concerns.

  • Routing rules: an array of { destinationType: "eoa" | "offramp" | "bridge", destination: address | fiatDetails, percentage: number }
  • Trigger configuration: “on_claim” (at launch), with “scheduled” and “threshold” as future additions
  • Session key status: active, revoked, expiring soon
  1. Employee claims from LlamaPay (either manually via Openfort SDK, or via session key if automated claiming is enabled)
  2. The indexer detects the LlamaPay withdrawal event
  3. A Convex action reads the employee’s routing rules
  4. For each routing destination, the backend constructs the appropriate transaction:
    • EOA transfer: direct USDC transfer via session key
    • Off-ramp: backend calls HoneyCoin API for a provider deposit address, then USDC transfer via session key (see Chapter 5)
    • Bridge: backend calls the bridge provider for deposit address/calldata, then executes via session key (see Chapter 6)
  5. Where possible, multiple transfers are batched into a single UserOp via executeBatch

What Does NOT Require Re-Granting the Session Key

Section titled “What Does NOT Require Re-Granting the Session Key”
  • Changing routing percentages
  • Adding or removing bank accounts or MoMo destinations (fiat details live in Convex)
  • Changing the trigger type
  • Pausing and resuming auto-routing
  • Swapping the off-ramp provider (HoneyCoin to Paychant)
  • Adding a new token type (e.g., DAI) to the whitelisted contracts
  • Session key expiry renewal (every 6 months)
  • Migrating from Path A to Path B (new session key whitelisting the CapxulRouter instead of USDC directly)

Tier 1: Automatic Recovery (Default, Invisible)

Section titled “Tier 1: Automatic Recovery (Default, Invisible)”

Handles 90%+ of cases. User signs in on a new device.

  1. User authenticates via Better Auth magic link
  2. Shield returns the encrypted recovery share
  3. Hot share server returns the encrypted hot share
  4. OpenSigner reconstructs the key from 2 of 3 shares
  5. User is back. They did not know recovery happened.

The orphaned device share from the old browser is useless alone.

If configured during wallet setup:

  1. Recovery share is encrypted with a user-chosen password
  2. Even if Shield is down AND the device share is gone, the user reconstructs from the password-encrypted share + hot share
  3. Strongest protection against infrastructure failure

Tier 3: On-Chain Social Recovery (Guardians)

Section titled “Tier 3: On-Chain Social Recovery (Guardians)”

Openfort’s smart contracts include an Argent-inspired guardian system.

Capxul’s guardian model:

  • Guardian 1: Capxul (backend holds a guardian key)
  • Guardian 2: The employing organization (optional, user-revocable)
  • Guardian 3: User-designated (friend, family, hardware wallet, optional)
  • Threshold: 2 of 3

Recovery flow:

  1. Recovery is initiated by calling startRecovery(newOwnerAddress) on the smart account
  2. Configurable time lock begins (e.g., 48 hours)
  3. Guardians approve. Openfort’s API allows guardians to sign via API call (gasless).
  4. If threshold is met and time lock expires, ownership transfers to the new signer
  5. Original owner can cancel at any time during the window (protects against rogue guardians)

After recovery: The smart account address stays the same. Salary streams, accrued balances, routing config (Convex) all persist. Only the signer changes.

On-chain social recovery protects against lost keys, not stolen keys. If a malicious actor has the current private key, they could cancel recovery attempts or drain the account before recovery completes. Mitigation: spending limits and time locks on large withdrawals, buying guardians time to lock the account.

Complete Workflow: Admin Creates Employees, Employees Get Paid

Section titled “Complete Workflow: Admin Creates Employees, Employees Get Paid”
  1. Org admin opens Capxul, enters email
  2. Better Auth sends magic link code, admin authenticates
  3. Openfort SDK creates admin’s embedded wallet + smart account
  4. Admin creates org profile (Convex)
  5. Admin creates or connects a Safe treasury, Capxul modules are installed

Admin uploads a CSV with employee emails and salary amounts. For each row, a Convex action runs:

// convex/actions/bulkOnboard.ts (Phase 1: using Openfort API)
export const onboardEmployee = action({
args: {
email: v.string(),
orgId: v.id("organizations"),
salaryAmount: v.number(),
},
handler: async (ctx, args) => {
// Phase 1: calls Openfort hosted API
// Phase 3: replaced with local CREATE2 computation via viem
const player = await openfortSDK.players.create({ name: args.email });
const account = await openfortSDK.accounts.create({
player: player.id,
chainId: TARGET_CHAIN_ID,
});
const userId = await ctx.runMutation(internal.users.create, {
email: args.email,
openfortPlayerId: player.id,
smartAccountAddress: account.address,
accountDeployed: false,
});
await ctx.runMutation(internal.orgMemberships.create, {
userId,
orgId: args.orgId,
salary: {
amount: args.salaryAmount,
currency: "USDC",
frequency: "monthly",
},
status: "active",
});
return { userId, smartAccountAddress: account.address };
},
});

Note: Openfort does not allow email changes after wallet pre-generation. If the admin entered the wrong email, Capxul creates a new player/wallet for the correct email, updates the stream target in the Safe module, and orphans the old player. Since pull payments mean no funds have moved to the wrong address, this is safe.

The on-chain Safe module tracks accrued amounts per recipient address. Funds remain in the Safe. Convex caches the stream parameters (rate, start time, total claimed). The frontend computes the live accruing balance client-side from those parameters, ticking up every frame. No blockchain polling.

  1. Enter email, Better Auth sends magic link code
  2. Enter code, authenticate, get Bearer token
  3. Openfort SDK gets encryption session via Shield
  4. OpenSigner generates or reconstructs the private key
  5. The signer deterministically matches the one pre-generated in Phase 2
  6. Employee sees their accrued salary balance on the dashboard
  1. Openfort SDK constructs a UserOperation (first claim includes initCode to deploy the smart account)
  2. Calls the Safe module’s claim() function
  3. Submitted via bundler, gas sponsored via paymaster
  4. Smart account deploys (first time only), claim executes
  5. Safe module transfers accrued USDC from Safe to employee’s smart account
  6. Event indexer updates Convex, dashboard balance updates in real-time

Employee has configured routing rules: 60% to MetaMask, 40% off-ramp to GTBank via HoneyCoin.

  1. Convex action reads routing config
  2. Openfort SDK constructs a batched UserOp: transfer 600 USDC to MetaMask, transfer 400 USDC to provider deposit address
  3. Session key authorizes the batch without manual signing
  4. Submitted via bundler, gas sponsored
  5. Event indexer updates Convex with transaction history
  6. HoneyCoin processes the off-ramp

Employee updates their MetaMask address in the Capxul app (Convex mutation). The on-chain salary stream is unaffected. It still targets the smart account. Next claim-and-route uses the new address automatically.