> ## Documentation Index
> Fetch the complete documentation index at: https://docs.privy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Using stateful policies with Privy

Daily transfer limits are a standard fraud and risk control in finance. Stateful aggregation policies enforce these limits at the wallet layer without a separate rate limiting system. Once a wallet reaches its rolling cap, Privy rejects signing requests until the window resets.

This recipe walks through enforcing a 24-hour USDC transfer cap on a server wallet. The same pattern applies to user withdrawal limits, hot wallet circuit breakers, or per-account spend controls.

This approach has two parts:

* **Aggregation**: Tracks the running sum of USDC `transfer` amounts from `eth_signTransaction` requests over a rolling time window
* **Policy**: References the aggregation and rejects signing if the running total would exceed the cap

<Warning>
  Aggregation values are updated **after** a request is successfully signed. This means multiple
  concurrent requests may all pass policy evaluation before any of their values are recorded.
  Stateful policies are designed for **disaster prevention**, not strict real-time enforcement. For
  tighter control, combine aggregation-based caps with per-transaction limits and rate limiting in
  the application layer.
</Warning>

<Steps>
  <Step title="Create a spend-tracking aggregation">
    Create an aggregation that sums the `amount` field from ERC-20 `transfer` calldata over a rolling 24-hour window. Scope it to the USDC contract so only USDC transfers count toward the cap.

    Aggregation creation is via the [REST API](/api-reference/aggregations/create). The Node SDK `aggregations` interface does not yet expose a `create` method.

    ```typescript {skip-check} theme={"system"}
    const PRIVY_APP_ID = process.env.PRIVY_APP_ID!;
    const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET!;

    // USDC on Sepolia. Replace with the target chain's USDC address.
    const USDC_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';

    const credentials = Buffer.from(`${PRIVY_APP_ID}:${PRIVY_APP_SECRET}`).toString('base64');

    const response = await fetch('https://api.privy.io/v1/aggregations', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'privy-app-id': PRIVY_APP_ID,
        Authorization: `Basic ${credentials}`
      },
      body: JSON.stringify({
        name: 'USDC daily transfer tracker',
        method: 'eth_signTransaction',
        metric: {
          field: 'transfer.amount',
          field_source: 'ethereum_calldata',
          function: 'sum',
          abi: [
            {
              type: 'function',
              name: 'transfer',
              stateMutability: 'nonpayable',
              inputs: [
                {type: 'address', name: 'to'},
                {type: 'uint256', name: 'amount'}
              ],
              outputs: [{type: 'bool'}]
            }
          ]
        },
        window: {
          type: 'rolling',
          seconds: 86400 // 24 hours
        },
        conditions: [
          {
            field_source: 'ethereum_transaction',
            field: 'to',
            operator: 'eq',
            value: USDC_ADDRESS
          }
        ]
      })
    });

    if (!response.ok) {
      throw new Error(`Failed to create aggregation: ${await response.text()}`);
    }

    const {id: aggregationId} = await response.json();
    ```

    Save the returned `aggregationId`. The next step uses it in the policy condition.

    <Info>
      Each Privy app supports a maximum of **10 aggregations**. If your app provisions many wallets,
      consider using `group_by` to partition a single aggregation by wallet address rather than creating
      one aggregation per wallet.
    </Info>
  </Step>

  <Step title="Create a policy with an aggregation cap">
    Create a policy with an `eth_signTransaction` rule that allows USDC transfers only while the 24-hour rolling sum stays at or below the cap. Set `field_source: 'reference'` and prefix the aggregation ID with `aggregation.` to reference it.

    ```typescript {skip-check} theme={"system"}
    import {PrivyClient} from '@privy-io/node';

    const privy = new PrivyClient({
      appId: PRIVY_APP_ID,
      appSecret: PRIVY_APP_SECRET
    });

    // 100 USDC cap: 100 × 10^6 base units = 100,000,000 = 0x5F5E100
    const SPENDING_CAP_HEX = '0x5F5E100';

    const policy = await privy.policies.create({
      name: 'USDC 100/24h transfer cap',
      version: '1.0',
      chain_type: 'ethereum',
      rules: [
        {
          name: 'Allow USDC transfers within 24h rolling cap',
          method: 'eth_signTransaction',
          action: 'ALLOW',
          conditions: [
            {
              field_source: 'ethereum_transaction',
              field: 'to',
              operator: 'eq',
              value: USDC_ADDRESS
            },
            {
              field_source: 'reference',
              field: `aggregation.${aggregationId}`,
              operator: 'lte',
              value: SPENDING_CAP_HEX
            }
          ]
        }
      ]
    });
    ```

    <Tip>
      USDC uses 6 decimal places. Convert a human-readable amount to base units before setting it as a
      hex cap value: `100 USDC = 100 × 10^6 = 100,000,000 = 0x5F5E100`.
    </Tip>

    <Warning>
      A policy denies any RPC method not explicitly covered by a rule. If this wallet also needs to sign
      typed data, call other contracts, or use other RPC methods, add explicit `ALLOW` rules for those
      methods.
    </Warning>

    Save `policy.id`. The wallet creation step and the spending-cap update step both need it.
  </Step>

  <Step title="Create a wallet with the policy">
    Create a server wallet and attach the policy using `policy_ids`:

    ```typescript {skip-check} theme={"system"}
    const wallet = await privy.wallets.create({
      chain_type: 'ethereum',
      policy_ids: [policy.id]
    });

    // Save wallet.id (used to sign transactions) and wallet.address (the on-chain address)
    ```

    The policy is now active on the wallet. Each `eth_signTransaction` request checks the rule before signing.
  </Step>

  <Step title="Sign and broadcast a USDC transfer">
    Use `eth_signTransaction` to sign the transaction. This is where Privy checks the policy and updates the aggregation. After signing, send the raw transaction via any RPC node.

    ```typescript {skip-check} theme={"system"}
    import {encodeFunctionData, erc20Abi, parseUnits, createPublicClient, http} from 'viem';
    import {sepolia} from 'viem/chains';

    const recipientAddress = '0x...';
    const amount = '25'; // USDC to send

    // Encode the ERC-20 transfer calldata
    const data = encodeFunctionData({
      abi: erc20Abi,
      functionName: 'transfer',
      args: [recipientAddress as `0x${string}`, parseUnits(amount, 6)]
    });

    // Sign via Privy. Policy and aggregation are evaluated here.
    const signResponse = await privy.wallets._rpc(wallet.id, {
      method: 'eth_signTransaction',
      chain_type: 'ethereum',
      params: {
        transaction: {
          from: wallet.address,
          to: USDC_ADDRESS,
          data,
          chain_id: sepolia.id // 11155111
        }
      }
    });

    // signResponse.data contains the signed transaction
    const {signed_transaction} = (signResponse.data as any).data;

    // Broadcast using viem or any Ethereum RPC client
    const publicClient = createPublicClient({
      chain: sepolia,
      transport: http(process.env.RPC_URL)
    });

    const txHash = await publicClient.sendRawTransaction({
      serializedTransaction: signed_transaction as `0x${string}`
    });
    ```

    The policy check is **forward-looking**: the engine checks whether the running total plus the current request amount would exceed the cap. For example, a wallet that has spent 90 USDC is blocked from a 15 USDC transfer, even though the cap is 100 USDC.
  </Step>

  <Step title="Handle policy violations">
    When a signing request would push the running total past the cap, Privy returns a `400` error with code `policy_violation`. Handle it and return a clear error:

    ```typescript {skip-check} theme={"system"}
    import {BadRequestError} from '@privy-io/node';

    async function sendUsdcWithBudgetGuard(
      walletId: string,
      walletAddress: string,
      recipient: string,
      amountUsdc: string
    ) {
      const data = encodeFunctionData({
        abi: erc20Abi,
        functionName: 'transfer',
        args: [recipient as `0x${string}`, parseUnits(amountUsdc, 6)]
      });

      try {
        const signResponse = await privy.wallets._rpc(walletId, {
          method: 'eth_signTransaction',
          chain_type: 'ethereum',
          params: {
            transaction: {
              from: walletAddress,
              to: USDC_ADDRESS,
              data,
              chain_id: sepolia.id
            }
          }
        });

        const {signed_transaction} = (signResponse.data as any).data;
        const txHash = await publicClient.sendRawTransaction({
          serializedTransaction: signed_transaction as `0x${string}`
        });

        return {status: 'submitted', txHash};
      } catch (error) {
        if (error instanceof BadRequestError && (error.error as any)?.code === 'policy_violation') {
          // Spending cap reached. 24-hour window has not yet rolled over.
          return {status: 'blocked', reason: 'spending_limit_reached'};
        }
        throw error;
      }
    }
    ```
  </Step>
</Steps>

## Common pitfalls

**Aggregation conditions and policy conditions are separate.** The aggregation's `conditions` array controls what gets tracked. The policy rule's `conditions` control what gets allowed or denied. If these diverge, the cap can reset without warning. For example, if the aggregation tracks USDC transfers to one contract but the policy rule covers all USDC transfers, spend to other contracts is excluded from the cap. Keep both condition sets in sync.

**`eth_sendTransaction` spend is invisible to aggregations.** Aggregations only track `eth_signTransaction` and `eth_signUserOperation` requests. If the wallet also handles `eth_sendTransaction` calls, that spend does not count toward the cap. This recipe uses `eth_signTransaction` throughout to track all outflow.

**`group_by` extraction failures deny the request.** When `group_by` is set and Privy cannot extract the grouping field from a transaction, the policy denies the request rather than falling back to a global bucket. This can happen when the calldata does not match the expected function signature or the ABI is absent. When the group key source is optional or variable, omit `group_by` and use per-wallet aggregations instead.

**ABI mismatch silently passes.** If the transaction calldata does not decode against the aggregation metric's ABI, the extracted value defaults to `0`. The transaction passes the policy check as if nothing was sent. An incorrect ABI means Privy never enforces the cap for those transactions. Verify ABI decoding before relying on the cap in production.

**Reverted transactions still count.** The aggregation updates when signed, not when confirmed on-chain. If a signed transaction reverts on-chain, the spend still counts. Gas failures and reverts do not cancel the aggregation increment. Plan for gas and slippage.

**The rolling window is continuous, not clock-based.** A 24-hour window means the last 86,400 seconds from the exact moment of the request. There is no midnight reset. A wallet that sends 100 USDC at 11:59 PM is blocked until 11:59 PM the following day, not until midnight.

## Patch behavior

Privy applies policy rule updates **in place**. The policy ID does not change, and wallets do not need updates after a rule change.

Privy recommends updating each rule with `_updateRule` rather than replacing the full policy with `_update`. Updating each rule avoids race conditions from concurrent changes. See [updating policy rules](/controls/policies/update-a-policy#updating-policy-rules-individually) for more detail.

If the policy has an `owner_id`, each update needs the owner's signature. See [authorization signatures](/api-reference/authorization-signatures) for details.

## Cleanup

When a wallet is retired, delete its policy and aggregation to stay within the 10-aggregation limit.

<Warning>
  Delete the policy **before** deleting the aggregation. If the aggregation is deleted while a
  policy still references it, all conditions referencing that aggregation evaluate to `false`, which
  denies signing requests.
</Warning>

```typescript {skip-check} theme={"system"}
// 1. Delete the policy
await privy.policies._delete(policy.id);

// 2. Delete the aggregation via the REST API
const deleteResponse = await fetch(`https://api.privy.io/v1/aggregations/${aggregationId}`, {
  method: 'DELETE',
  headers: {
    'privy-app-id': PRIVY_APP_ID,
    Authorization: `Basic ${credentials}`
  }
});

if (!deleteResponse.ok) {
  throw new Error(`Failed to delete aggregation: ${await deleteResponse.text()}`);
}
```
