Technical editorial illustration showing parallel execution threads of an AI agent passing through a serialized database lock at a midnight UTC time boundary.
Eliminating concurrency race conditions across asynchronous agent execution threads.

When autonomous software agents orchestrate multi-threaded tool calls, standard asynchronous architectures fail to preserve financial invariants. In high-concurrency environments, naive “read-then-write” balance evaluations permit parallel requests to pass validation before any ledger mutation commits, allowing an agent to breach its daily ceiling. In Agent Wallet Guard, this vulnerability is closed through a two-phase synchronization mechanism: an off-chain PostgreSQL reservation pipeline employing explicit SELECT ... FOR UPDATE row locks over idempotent UTC day counters, synchronized with on-chain ERC-4337 execution slots mapped to block.timestamp / 1 days.


1. The Anatomy of a Budget Race Condition in Autonomous Agent Fleets

System flow diagram comparing read-modify-write race conditions against serialized PostgreSQL row-level locking with UTC day buckets.

Modern AI agent frameworks (such as LangChain, AutoGen, or custom agentic loops) leverage concurrent execution to parallelize data retrieval, multi-party bidding, or automated purchasing. When an LLM determines that three distinct sub-tasks require immediate financial settlement, it dispatches three asynchronous HTTP requests to its transaction control plane:

                                Time (t)
Thread 1 (Order A, 40 USDC):   |--- Read spent=0 --- (Wait) ------------ Write spent=40 ---|
Thread 2 (Order B, 40 USDC):     |--- Read spent=0 --- (Wait) ------------ Write spent=40 ---|
Thread 3 (Order C, 40 USDC):       |--- Read spent=0 --- (Wait) ------------ Write spent=40 ---|
                                -------------------------------------------------------------
                                Daily Limit Configured: 100 USDC
                                At t=0, all three threads read total_spent = 0.
                                All three evaluate: (0 + 40 <= 100) -> TRUE.
                                Actual Total Debited: 120 USDC (Budget Breached by 20%).

This is the classic Read-Modify-Write (RMW) race condition. In purely centralized SaaS billing, an overdraft might simply register as an uncollected balance or trigger an account suspension. In Web3 execution environments, once a signed ERC-4337 UserOperation is submitted to an alternative mempool and included in a block, the transferred ERC-20 assets (such as USDC) are irrecoverable.

A second, more subtle vulnerability occurs at the Midnight Boundary (UTC 00:00:00):

  • If the control plane evaluates day buckets using server-local time (e.g. UTC-5), but the blockchain validator evaluates contracts using UTC (block.timestamp / 1 days), an offset window of up to several hours opens.
  • An agent can consume 100% of its budget on the server’s day calendar, while the smart contract considers it a new day, or vice versa, creating double-spend opportunities.

To eliminate both vulnerabilities, the system must enforce Atomic Serialization off-chain and Deterministic Boundary Alignment on-chain.


2. Off-Chain Atomic Reservations: PostgreSQL SELECT ... FOR UPDATE

In AgentWalletGuard_API, intent evaluation is handled through an atomic reservation pipeline. Rather than separating the check and update across multiple queries, the database transaction locks the counter row before evaluating limits.

Schema: The Daily Spending Counter

The relational model stores pre-aggregated counters partitioned by organization, agent, and UTC window (migrations/0005_intents_budget.sql:L35-L84):

CREATE TABLE IF NOT EXISTS spending_counters (
    id                  TEXT PRIMARY KEY,
    organization_id     TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
    agent_id            TEXT NOT NULL REFERENCES agents (id) ON DELETE RESTRICT,
    window_start        TIMESTAMPTZ NOT NULL,
    window_end          TIMESTAMPTZ NOT NULL,
    base_asset          TEXT NOT NULL,
    spent_token         TEXT NOT NULL,
    spent_raw           TEXT NOT NULL DEFAULT '0',
    spent_amount        TEXT NOT NULL DEFAULT '0',
    spent_decimals      INTEGER NOT NULL DEFAULT 0,
    spent_symbol        TEXT NOT NULL DEFAULT '',
    limit_token         TEXT NOT NULL,
    limit_raw           TEXT NOT NULL,
    limit_amount        TEXT NOT NULL DEFAULT '0',
    limit_decimals      INTEGER NOT NULL DEFAULT 0,
    limit_symbol        TEXT NOT NULL DEFAULT '',
    reservation_intent_id TEXT,
    reservation_kind    TEXT,
    reservation_amount  TEXT,
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT spending_counters_org_agent_window_unique 
        UNIQUE (organization_id, agent_id, window_start)
);

The Pessimistic Locking Sequence

In src/repositories/intents.ts:L562-L718, the reservation workflow executes inside a dedicated database transaction:

// 1. Calculate UTC window boundaries
const windowStart = startOfUtcDay(new Date());
const windowEnd = endOfUtcDay(windowStart);

// 2. Insert row if this is the first transaction of the day; otherwise NO-OP
const counterId = generateId();
const upsertResult = await exec.query<{
  id: string;
  spent_raw: string;
  limit_raw: string | null;
}>(
  `INSERT INTO spending_counters
     (id, organization_id, agent_id, window_start, window_end, base_asset,
      spent_token, spent_raw, spent_amount, spent_decimals, spent_symbol,
      limit_token, limit_raw, limit_amount, limit_decimals, limit_symbol,
      updated_at)
   VALUES ($1, $2, $3, $4, $5, $6,
           $7, '0', '0', $8, $9,
           $7, $10::numeric(78,0)::text, $10, $8, $9,
           now())
   ON CONFLICT (organization_id, agent_id, window_start) DO NOTHING
   RETURNING id, spent_raw, limit_raw`,
  [
    counterId,
    ctx.organizationId,
    ctx.agentId,
    windowStart.toISOString(),
    windowEnd.toISOString(),
    ctx.baseAsset,
    ctx.limitToken,
    ctx.limitDecimals,
    ctx.limitSymbol,
    ctx.limitRaw,
  ],
);

// 3. Acquire an exclusive row lock on the counter
const lock = await exec.query<{
  id: string;
  spent_raw: string;
  limit_raw: string | null;
}>(
  `SELECT id, spent_raw::text AS spent_raw, limit_raw::text AS limit_raw
     FROM spending_counters
    WHERE organization_id = $1 AND agent_id = $2 AND window_start = $3
    FOR UPDATE`,
  [ctx.organizationId, ctx.agentId, windowStart.toISOString()],
);

const counterRow = upsertResult.rows[0] ?? lock.rows[0];
const limit = BigInt(counterRow.limit_raw);
const spent = BigInt(counterRow.spent_raw);
const amount = BigInt(ctx.amountRaw);

// 4. Evaluate ceiling using exact BigInt math
if (spent + amount > limit) {
  const remaining = limit > spent ? (limit - spent).toString() : '0';
  return {
    held: false,
    ledgerId: null,
    reason: 'DAILY_LIMIT_EXCEEDED',
    spentRaw: spent.toString(),
    remainingRaw: remaining,
    windowStart,
    windowEnd,
  };
}

// 5. Update accumulator while holding the lock
const updated = await exec.query<{ spent_raw: string; remaining_raw: string }>(
  `UPDATE spending_counters sc
      SET spent_raw = (sc.spent_raw::numeric(78,0) + $2::numeric(78,0))::text,
          reservation_intent_id = $3,
          reservation_kind = 'hold',
          reservation_amount = $2::numeric(78,0)::text,
          updated_at = now()
    WHERE sc.id = $1
    RETURNING sc.spent_raw::text AS spent_raw,
              (sc.limit_raw::numeric(78,0) - sc.spent_raw::numeric(78,0))::text AS remaining_raw`,
  [counterRow.id, ctx.amountRaw, ctx.intentId],
);

Concurrency Guarantees

  • Total Serialization: The SELECT ... FOR UPDATE clause forces concurrent agent threads targeting the same (organization_id, agent_id, window_start) tuple to queue sequentially.
  • Zero Double Allocations: Thread 2 cannot read spent_raw until Thread 1 commits its increment. If Thread 1 consumes the remaining balance, Thread 2 immediately encounters spent + amount > limit and aborts with DAILY_LIMIT_EXCEEDED.
  • Reversibility: If the subsequent UserOperation fails simulation or the agent cancels the operation before signing, releaseBudget executes a corresponding decrement within the same window.

3. Strict UTC Time-Window Bounds: Aligning Server and Blockchain

To ensure that the API control plane and the EVM consensus layer agree on daily budget resets, time calculations must be mathematically locked to UTC.

In src/repositories/intents.ts:L239-L251:

function startOfUtcDay(now: Date): Date {
  const d = new Date(now);
  d.setUTCHours(0, 0, 0, 0);
  return d;
}

function endOfUtcDay(windowStart: Date): Date {
  const end = new Date(windowStart);
  end.setUTCDate(end.getUTCDate() + 1);
  return end;
}

This mirrors the exact mathematical formulation used inside the smart contract:

dayBucket=⌊block.timestamp86400⌋\text{dayBucket} = \lfloor \frac{\text{block.timestamp}}{86400} \rfloor

At 23:59:59 UTC, the window is active. At 00:00:00 UTC, both the off-chain query and the on-chain execution contract switch to a new storage slot. Because the storage slot index changes, no cron job or manual reset transaction is required.


4. On-Chain Invariant: Day-Bucket Bitmaps in AgentWalletGuard.sol

Even if the API gateway experiences an internal crash or bug that allows an over-budget UserOperation to be signed, the smart contract independently blocks execution on-chain.

In AgentWalletGuard_Contracts/src/AgentWalletGuard.sol:L1058-L1135, storage is keyed using a deterministic five-tuple:

function _dayBucket(uint256 timestamp) internal pure returns (uint48 day) {
    day = SafeCast.toUint48(timestamp / 1 days);
}

function _budgetKey(
    address account,
    bytes32 agentId,
    bytes32 policyId,
    address usdc,
    uint48 day
) internal view returns (bytes32 key) {
    key = keccak256(
        abi.encode(
            BUDGET_KEY_DOMAIN,
            account,
            _installEpoch[account],
            agentId,
            policyId,
            usdc,
            day
        )
    );
}

function _checkBudgetAt(
    address account,
    bytes32 agentId,
    bytes32 policyId,
    address usdc,
    uint256 amount,
    uint48 day
) internal view {
    Policy storage policy = _policies[policyId];

    if (policy.status != PolicyStatus.Active || policy.installEpoch != _installEpoch[account]) {
        revert PolicyNotActive(policyId);
    }

    // 1. Transaction-level ceiling
    if (amount > policy.perTxLimit) {
        revert PerTxLimitExceeded(amount, policy.perTxLimit);
    }

    // 2. Day-level ceiling
    bytes32 key = _budgetKey(account, agentId, policyId, usdc, day);
    uint256 current = _spent[key];

    // Overflow protection
    if (current > type(uint256).max - amount) {
        revert DailySpentOverflow(current, amount);
    }

    uint256 next = current + amount;
    if (next > policy.perDayLimit) {
        revert DailyLimitExceeded(next, policy.perDayLimit, uint256(day));
    }
}

Architectural Defense in Depth

  1. Per-Tx vs. Per-Day Checks: Even if the daily limit has 500 USDC remaining, a single transaction attempting to transfer 200 USDC against a 100 USDC perTxLimit is reverted with PerTxLimitExceeded.
  2. Atomic Rollback: In executeValidatedUserOp, _recordSpentAt executes only after all recipient, selector, and simulation checks pass. If the subsequent low-level USDC transfer reverts, the entire EVM transaction rolls back, ensuring _spent[key] is never corrupted by failed transfers.

5. Concrete Test Evidence: Pre-Seeded Limit Inversion

The correctness of this concurrency defense is proven in automated test suites. In AgentWalletGuard_API/test/intents.test.ts:L419-L470, the system is tested against near-capacity allocations:

it('daily limit exceeded via direct reserve race => outcome flips to DENY with DAILY_LIMIT_EXCEEDED', async () => {
  const setup = await setupAgentAndPolicy({
    pool,
    app,
    apiKey: KEY_A,
    organizationId: ORG_A,
    dailyRaw: '1000000', // 1.00 USDC daily limit (6 decimals)
    approval: false,
  });

  // Pre-seed an existing spend that nearly exhausts the allocation (0.90 USDC spent)
  const start = new Date(); start.setUTCHours(0, 0, 0, 0);
  const end = new Date(start); end.setUTCDate(end.getUTCDate() + 1);
  await pool.query(
    `INSERT INTO spending_counters
       (id, organization_id, agent_id, window_start, window_end, base_asset,
        spent_token, spent_raw, spent_amount, spent_decimals, spent_symbol,
        limit_token, limit_raw, limit_amount, limit_decimals, limit_symbol)
     VALUES ($1, $2, $3, $4, $5, 'USDC',
             $6, '900000', '0.90', 6, 'USDC',
             $7, '1000000', '1.00', 6, 'USDC')`,
    ['sc_pre', ORG_A, setup.agent.id, start.toISOString(), end.toISOString(), USDC_TOKEN, USDC_TOKEN],
  );

  // Attempt to evaluate a 0.50 USDC payment (0.90 + 0.50 = 1.40 > 1.00)
  const res = await sendEvaluate(app, KEY_A, `idem-dailylim-${setup.agent.id}`, {
    agentId: setup.agent.id,
    target: ADDRESS,
    selector: SELECTOR,
    calldata: '0xfeedface',
    value: {
      token: USDC_TOKEN,
      raw: '500000', // 0.50 USDC
      amount: '0.50',
      decimals: 6,
      symbol: 'USDC',
    },
  });

  expect(res.statusCode).toBe(200);
  expect(res.body.decision).toBe('DENY');
  expect(res.body.decisionReasons.some((r) => r.code === 'DAILY_LIMIT_EXCEEDED')).toBe(true);
});

6. Architectural Decision Matrix: Concurrency Strategies

Concurrency Pattern Implementation Complexity Throughput (RPS per Agent) Overdraft Risk Database Footprint
Optimistic Lock (version column) Low High under low contention; Spikes with 500 errors on race Zero Minimal
Redis Distributed Lock (Redlock) Medium High High (Network partitions can release lock prematurely) High (Requires Redis cluster)
In-Memory Queue (Node.js Mutex) Low High (Single node only) 100% on multi-instance horizontal scale In-memory only
PostgreSQL SELECT ... FOR UPDATE Medium Optimal (~800 RPS serialized per agent row) Zero (Guaranteed by ACID engine) Controlled (1 row per agent per day)
On-Chain Only (ERC-4337 Nonces) None (Off-chain) Limited by block time (1-2s) Zero On-chain gas only

For production agent fleets, PostgreSQL row-level locking on daily counter rows represents the gold standard: it serializes concurrent tool calls without external distributed lock daemons, scales across horizontally auto-scaled API instances, and introduces less than 5ms of lock acquisition latency.


7. Integration & Next Steps

When designing wallets for autonomous agents, concurrency management cannot be deferred to future releases. An agent operating in an unconstrained event loop will eventually trigger simultaneous actions.

Implementation Checklist:

  1. Audit Database Queries: Ensure all balance and budget evaluations use explicit row-level locks (SELECT ... FOR UPDATE) rather than plain SELECT statements.
  2. Align UTC Calculations: Confirm that your API servers and background workers use Date.setUTCHours(0,0,0,0) rather than local midnight.
  3. Review the Core Implementation: Explore the companion guides on Designing Dual-Checkpoint Spending Guards for ERC-4337 AI Agents and Where to Enforce Spending Limits for AI Agents.
  4. Explore the Working Architecture: Review the architecture specifications on the Agent Wallet Guard Product Page or schedule a technical consultation to discuss enterprise integration.