← All posts

HD Wallet Rotation for x402 eSIM Provisioning at Scale

Production architecture for BIP-32 key derivation, gas batching, and privacy-preserving payment addresses in x402 eSIM dispatch at 2400 activations/hour

2026-07-22·13 min read·eSIMx402 Team·architecture / x402 / polygon / production / security

When you run an agent that provisions hundreds or thousands of eSIMs per hour via x402, you face a scaling problem most demos skip: every HTTP 402 challenge includes a payment address, and reusing the same address across agents leaks correlation metadata. If 10,000 agents all pay 0xABC..., any observer can cluster them. The solution is hierarchical deterministic (HD) wallet rotation—deriving a fresh deposit address per eSIM activation, settling gas in batches, and avoiding on-chain linkability between agent sessions.

This post walks through our production HD wallet architecture on Polygon for x402 eSIM dispatch. We explain BIP-32/BIP-44 key derivation, Coinbase facilitator handshake patterns, gas batching strategies, and the latency/privacy tradeoffs we measured at 2,400 activations per hour peak load in June 2026.

Why HD wallets for x402 eSIM

The x402 protocol requires the API server (our eSIM dispatch endpoint) to return a 402 Payment Required response with a Pay header containing a stablecoin address and amount. The agent sends USDC to that address, the facilitator (Coinbase's x402 service) confirms settlement on-chain, then our backend activates the eSIM and returns the profile download URL.

If you use a single static address for all agent payments, you get:

  • Correlation risk. Blockchain explorers show 0xABC... received 10,000 micropayments from different agents. An adversary clusters them as "same operator."
  • Accounting hell. Which inbound 0.50 USDC deposit maps to which agent's eSIM request? You need off-chain event logs to reconcile.
  • Rate-limit exposure. Coinbase facilitator scans incoming transactions to the address. If 1,000 agents hit the same address in 60 seconds, the facilitator's webhook delivery might lag or throttle.

HD wallets solve this by deriving a unique child address per activation from a master seed. Each agent payment lands at a distinct address (e.g., m/44'/966'/0'/0/58291 for activation #58291). You sweep funds to a hot wallet in batches, keeping the master key offline. On-chain, payments look unrelated—no single address accumulates history.

The tradeoff: key derivation latency and gas overhead for sweeps. We measured 1.2ms median derivation time (BIP-32 HMAC-SHA512 chain) and 0.09 USDC average gas per 50-address sweep on Polygon. For agents activating ≤10 eSIMs per minute, the latency is invisible. For burst workloads (200+ simultaneous activations), you pre-derive a pool.

BIP-32/BIP-44 derivation path structure

We use the standard BIP-44 hierarchy: m / purpose' / coin_type' / account' / change / address_index. For Polygon USDC payments:

  • purpose' = 44' (BIP-44)
  • coin_type' = 60' (Ethereum — Polygon is EVM-compatible)
  • account' = 0' (single logical account for all eSIM dispatch)
  • change = 0 (external addresses, not change)
  • address_index = 0, 1, 2, ... (incremented per activation)

The master seed is a 24-word BIP-39 mnemonic stored in AWS Secrets Manager (encrypted at rest, access-logged). The derivation happens in a Lambda function that runs in the VPC, never exposing the seed outside AWS.

Here's the derivation code (Node.js, using ethers v6 and @scure/bip39):

import { HDNodeWallet } from 'ethers';
import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';

// One-time setup: generate master seed (store in Secrets Manager)
const mnemonic = generateMnemonic(wordlist, 256); // 24 words
const seed = mnemonicToSeedSync(mnemonic);

// In production: load mnemonic from env, derive child address
const MASTER_MNEMONIC = process.env.MASTER_MNEMONIC; // from Secrets Manager
const masterNode = HDNodeWallet.fromPhrase(MASTER_MNEMONIC);

function deriveAddressForActivation(activationIndex) {
  const path = `m/44'/60'/0'/0/${activationIndex}`;
  const childNode = masterNode.derivePath(path);
  return childNode.address; // 0x...
}

// Example: activation #58291
const depositAddress = deriveAddressForActivation(58291);
console.log(depositAddress); // 0x9fE46...

Each activation increments activationIndex (stored in DynamoDB as a monotonic counter with conditional writes to prevent collisions). Derivation is deterministic—if the Lambda crashes and retries, the same index yields the same address.

Facilitator handshake and payment confirmation

When an agent calls POST /v1/esim/activate with a country code and data plan, our backend:

  1. Derives the next deposit address (increments counter, calls deriveAddressForActivation).
  2. Returns 402 Payment Required with header: Pay: ethereum:polygon:usdc 0x9fE46... 0.50 (0.50 USDC to the derived address on Polygon).
  3. Registers a webhook subscription with Coinbase facilitator for that address (facilitator watches Polygon for incoming USDC transfers).
  4. Agent sends 0.50 USDC to 0x9fE46... via its wallet.
  5. Facilitator detects the transfer (typically 8-12 seconds on Polygon including block confirmation), calls our webhook with proof.
  6. We validate the proof (signature check, amount match), activate the eSIM via the carrier API, return the download URL.

The webhook payload includes the transaction hash and block number. We store this in DynamoDB alongside the activation record for auditability.

Latency breakdown (P50 across 120k activations in June 2026):

  • Derivation: 1.2ms
  • 402 response generation: 3.1ms
  • Agent wallet broadcast: 2.4s (median, varies by agent's RPC endpoint)
  • Polygon block confirmation: 6.8s (2-second block time + facilitator polling)
  • Webhook delivery + eSIM activation: 1.9s
  • Total end-to-end: 11.2s (agent calls /activate to receiving profile URL)

The 6.8s Polygon settlement is the bottleneck. Base (Coinbase's L2) has 2-second blocks but higher USDC gas at low amounts. We chose Polygon because the gas savings (0.003 USDC per transfer vs 0.011 on Base for sub-$1 amounts) outweigh the 4s latency penalty for our workload.

Gas batching and sweep strategy

Each derived address receives exactly one USDC deposit (the agent payment). After the eSIM activates, the address sits idle with a small balance (typically 0.50 USDC minus facilitator fee, which the facilitator doesn't take—agents pay the full amount, we receive it all).

We sweep funds to a hot wallet every 50 activations or every 10 minutes, whichever comes first. The sweep transaction sends USDC from 50 addresses to the hot wallet in a single multicall (using a custom contract that batches transferFrom calls to save gas).

Sweep gas cost (Polygon, measured June 2026):

  • 50-address batch: ~0.09 USDC (including MATIC gas converted to USDC equivalent at 0.68 USDC/MATIC)
  • Per-address gas: 0.0018 USDC
  • Alternative (individual sweeps): 0.0032 USDC per address

Batching saves 44% on gas. The tradeoff: funds sit in derived addresses for up to 10 minutes before consolidation.

Here's the sweep contract (Solidity, deployed on Polygon):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract USDCSweeper {
    address public immutable hotWallet;
    IERC20 public immutable usdc;

    constructor(address _hotWallet, address _usdc) {
        hotWallet = _hotWallet;
        usdc = IERC20(_usdc);
    }

    function sweepBatch(address[] calldata sources) external {
        require(msg.sender == hotWallet, "unauthorized");
        for (uint i = 0; i < sources.length; i++) {
            uint balance = usdc.balanceOf(sources[i]);
            if (balance > 0) {
                usdc.transferFrom(sources[i], hotWallet, balance);
            }
        }
    }
}

In production, derived addresses are CREATE2 smart wallets (ERC-4337 style) that auto-approve the sweeper contract on deployment. This avoids the need for separate approve() transactions. The CREATE2 wallet bytecode is 180 bytes; deployment costs 0.0021 USDC gas on Polygon. We pre-deploy wallets in batches of 100 during off-peak hours to amortize gas.

Failover and address reuse policy

If an activation fails (agent never pays, webhook times out, carrier API rejects the request), we do not reuse the derived address. The activationIndex counter only increments, never decrements. This prevents edge cases where:

  1. Agent A requests activation #1000, gets address 0xAAA...
  2. Payment times out after 5 minutes
  3. We reuse index 1000 for agent B
  4. Agent A's delayed payment arrives, activates agent B's eSIM

Instead, failed activations leave a gap in the index sequence. Over 6 months, we had 1.2% failed activations (mostly agent wallet RPC timeouts). The unused addresses sit empty—Polygon doesn't charge rent, so there's no cost. If you're paranoid about address exhaustion, BIP-44 supports 2^31 addresses per account (2.1 billion). At 2,400 activations/hour, you'd exhaust the space in 99 years.

For agent retries: if the same agent calls /activate twice with identical parameters within 10 minutes, we return the same derived address from the first call (idempotency key = hash of agent pubkey + country + plan SKU). The agent can pay the same address again.

Privacy and on-chain linkability

From a blockchain observer's perspective, derived addresses appear unrelated. Each address receives one USDC deposit from a different agent wallet, then (10 minutes later) sends its balance to the hot wallet. The hot wallet address is public (published in our pricing page fee breakdown), but linking agent → derived address → hot wallet requires either:

  • Timing correlation ("this agent called /activate at 14:03:22 UTC, and 0x9fE46... received USDC at 14:03:30 UTC")
  • Off-chain metadata (webhook logs, which we don't publish)

We don't claim perfect unlinkability—if an adversary controls the facilitator or runs a Polygon archival node with transaction timestamps, they can correlate. But compared to a single static address (where all agents visibly pay the same 0xABC...), HD rotation raises the correlation cost by 3-4 orders of magnitude.

For agents that require stronger privacy: use Tor or a VPN when calling our API (hides IP), rotate agent wallet addresses (don't reuse the same 0x... to pay multiple eSIM activations), and stagger activation requests. We log IP addresses for 7 days (DDoS mitigation), then delete them.

Key rotation and disaster recovery

The master seed lives in AWS Secrets Manager, replicated to us-east-1 and eu-west-1. If the primary region fails, we failover to the replica within 90 seconds (RTO measured in chaos drills). The seed is encrypted with a KMS key that requires 2-of-3 admin approval for decryption.

We rotate the master seed every 12 months. Rotation procedure:

  1. Generate a new 24-word mnemonic (offline, on an air-gapped laptop).
  2. Store in Secrets Manager as MASTER_MNEMONIC_v2.
  3. Deploy a code change that checks the current date: if date >= 2027-07-01, use v2, else use v1.
  4. On the cutover date, all new activations derive from v2. Old addresses derived from v1 remain valid.
  5. After 30 days (max agent retry window), delete v1 from Secrets Manager.

Disaster recovery (master seed leak): if the seed is compromised, an attacker can derive all past and future addresses. They can't steal funds already swept to the hot wallet, but they can monitor future activations and front-run agent payments. Mitigation: rotate the seed immediately (emergency rotation SOP takes 15 minutes), invalidate all pending 402 challenges.

Performance at scale: 2,400 activations/hour

Our June 2026 peak was 2,417 activations in one hour (June 14, 19:00-20:00 UTC, driven by a crypto conference in Singapore where 80+ agent demos hit our API). Metrics:

  • Derivation throughput: Lambda auto-scaled to 12 concurrent executions, each deriving 200 addresses/sec. No bottleneck.
  • Facilitator webhook latency: P99 was 18.3 seconds (vs 8.2s P50). Coinbase's webhook SLA is <30s; we stayed within it.
  • Sweep gas: 48 batches × 0.09 USDC = 4.32 USDC total. Per-activation gas: $0.0018.
  • DynamoDB throttles: zero. Our activationIndex counter uses on-demand billing; it scaled to 2,400 writes/hour without provisioned capacity.

The system handled the load with zero manual intervention. Post-peak, we swept 2,400 addresses in 48 batches over 80 minutes. Total USDC collected: 1,208 USDC (average agent payment: 0.50 USDC). Gas overhead: 0.36% of revenue.

Comparison to static address and custodial alternatives

Three architecture choices for x402 eSIM payments:

Approach Privacy Gas cost Operational complexity Agent UX
Static address (single 0xABC... for all agents) Low (all payments visible) $0.0032/activation (no batching) Lowest (no derivation, manual sweeps) Simple (agents cache one address)
HD wallet rotation (this post) Medium (per-activation addresses, observable via timing) $0.0018/activation (batched sweeps) Medium (key mgmt, auto-sweep logic) Simple (agents parse 402 header per request)
Custodial facilitator (Coinbase holds funds, settles to us weekly) High (agent payments never hit our addresses) $0.00/activation (facilitator absorbs gas) Lowest (no on-chain ops) Simple (agents pay facilitator, not us)

We chose HD rotation because custodial facilitators add a trust dependency and weekly settlement breaks our cash-flow model (we pay carrier APIs within 24 hours of activation).

Integration example: Python agent with HD wallet payments

Here's a minimal Python agent that activates an eSIM via our x402 API, handling the 402 challenge with an ethers-based wallet:

import requests
from eth_account import Account
from web3 import Web3
import os
import time

# Agent's wallet (BIP-39 funded with USDC on Polygon)
AGENT_PRIVATE_KEY = os.getenv("AGENT_PRIVATE_KEY")
account = Account.from_key(AGENT_PRIVATE_KEY)

# Polygon RPC and USDC contract
w3 = Web3(Web3.HTTPProvider("https://polygon-rpc.com"))
USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"  # Polygon USDC
usdc_abi = [...]  # ERC-20 ABI (omitted for brevity)
usdc = w3.eth.contract(address=USDC_ADDRESS, abi=usdc_abi)

def activate_esim(country_code, data_plan_gb):
    # Step 1: Request eSIM activation (expect 402)
    resp = requests.post("https://esimx402.com/v1/esim/activate", json={
        "country": country_code,
        "plan_gb": data_plan_gb
    })
    assert resp.status_code == 402, f"Expected 402, got {resp.status_code}"
    
    # Step 2: Parse Pay header: "ethereum:polygon:usdc <address> <amount>"
    pay_header = resp.headers["Pay"]
    parts = pay_header.split()
    deposit_address = parts[1]  # e.g., 0x9fE46...
    amount_usdc = float(parts[2])  # e.g., 0.50
    
    # Step 3: Send USDC to deposit address
    amount_wei = w3.to_wei(amount_usdc, "mwei")  # USDC has 6 decimals
    tx = usdc.functions.transfer(deposit_address, amount_wei).build_transaction({
        "from": account.address,
        "nonce": w3.eth.get_transaction_count(account.address),
        "gas": 100000,
        "gasPrice": w3.eth.gas_price
    })
    signed = account.sign_transaction(tx)
    tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
    print(f"Payment sent: {tx_hash.hex()}")
    
    # Step 4: Poll for eSIM activation
    for i in range(30):  # poll for 30 seconds
        time.sleep(1)
        retry_resp = requests.post("https://esimx402.com/v1/esim/activate", json={
            "country": country_code,
            "plan_gb": data_plan_gb
        }, headers={"X-Payment-TxHash": tx_hash.hex()})
        if retry_resp.status_code == 200:
            profile_url = retry_resp.json()["profile_url"]
            print(f"eSIM activated: {profile_url}")
            return profile_url
    raise TimeoutError("eSIM activation timed out")

# Example: activate 1GB eSIM for Japan
activate_esim("JP", 1)

This agent derives no HD wallets itself—it pays whatever address the 402 response specifies. Our backend handles the HD derivation. The agent's wallet can be a simple funded account; no need for BIP-32 on the agent side.

When NOT to use HD wallet rotation

Skip HD rotation if:

  • You're activating <100 eSIMs/month. The operational overhead (key management, sweep automation, DynamoDB counters) isn't worth it. Use a static address and manually reconcile payments.
  • You're already using a custodial facilitator. If Coinbase (or another facilitator) holds the funds and settles to you weekly, you don't control the deposit addresses—HD rotation is moot.
  • Your agents require instant withdrawals. HD rotation batches sweeps every 10 minutes. If you need funds in the hot wallet within seconds of payment, you'd have to sweep every address individually.
  • Privacy isn't a concern. If all your agents are internal (not customer-facing), and you're fine with on-chain clustering, a static address is faster to deploy.

For everyone else—agents provisioning eSIMs at scale, especially multi-tenant setups where different customers' agents share your infrastructure—HD rotation is the production-grade answer.

Next steps

If you're building an agent that needs cellular connectivity:

  1. Read the quickstart guide (5-minute integration, uses a static test address for simplicity).
  2. Check our pricing to see gas costs broken down by chain (Polygon, Base, Arbitrum).
  3. Review the x402 protocol docs for the full Pay header spec and webhook signature validation.

For production deployments at >500 activations/month, we offer dedicated HD wallet pools (you get a reserved address range, e.g., indices 100000-199999, isolated from other customers). Email scale@esimx402.com with your expected volume.

RELATED