Skip to main content

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.

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
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.
1

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. The Node SDK aggregations interface does not yet expose a create method.
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.
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.
2

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.
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
        }
      ]
    }
  ]
});
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.
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. See forward compatibility for a recommended catch-all rule pattern.
Save policy.id. The wallet creation step and the spending-cap update step both need it.
3

Create a wallet with the policy

Create a server wallet and attach the policy using policy_ids:
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.
4

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.
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.
5

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:
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;
  }
}

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 for more detail. If the policy has an owner_id, each update needs the owner’s signature. See authorization signatures for details.

Cleanup

When a wallet is retired, delete its policy and aggregation to stay within the 10-aggregation limit.
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.
// 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()}`);
}