Skip to main content

Onboarding

Stateful policy lets you evaluate requests against both the current transaction and accumulated historical state.

Use it when stateless checks are not enough, for example:

  • daily spend limits
  • transaction count limits
  • cumulative caps per recipient or per chain

5-minutes first experiment

Goal: allow Alice to send USDC only when the rolling 24h total to the same recipient is <= 1000.

  1. Create one state controller (aggregation config).
  2. Reference that controller in your policy condition (state.id) and update the policy.
  3. Send transactions and observe state entries grow per partition.
  • First, create the state controller for rolling accumulation:
curl -sS -X POST "$WALLET_PROVIDER_BE_URL/v2/rest/createStateController" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"key_id": "alice_key_id",
"description": "Rolling 24h spend per recipient",
"method": "sum",
"window": { "type": "rolling", "seconds": 86400 },
"partition_by": [
{ "transaction_type": "erc20", "transaction_attr": "abi:to" }
]
},
"userSignatures": <...>
}'
  • Then, use the returned controller_id in your policy and update the policy by following the existing policy update flow in Policy Management: Duo, Trio, Silent Network.:
{
"version": "1.0",
"description": "Alice rolling 24h USDC limit per recipient",
"rules": [
{
"description": "Allow if rolling spend including current transfer is <= 1000",
"issuer": [{ "type": "UserId", "id": "alice_user_id" }],
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"transaction_type": "erc20",
"transaction_attr": "receiver",
"operator": "eq",
"value": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
},
{
"transaction_type": "erc20",
"transaction_attr": "amount",
"operator": "lte",
"value": 1000,
"abi": {
"name": "transfer",
"type": "function",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "value", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}
  • Finally, transfer amounts for transactions that share the same to value are accumulated in the same rolling window bucket.

Accumulated values only increase after a request is successfully signed. Because of this, several simultaneous requests might be approved before the total value reflects them. These policies act as a safety valve for disaster prevention rather than a precise, real-time lock. For better security, layer these high-level totals with strict limits on every individual transaction.

For API-level request and response details of controller endpoints, see State Controller Management: Duo, Trio, Silent Network.

Core components

ComponentWhat it isWhy it matters
policy_state_controllersAggregation config (sum or count) with window and partition_byDefines how to accumulate and slice state
policy_state_entriesRuntime values keyed by (controller_id, partition_key)Stores current total/count for one partition
condition.stateLink from policy condition to controller (state.id)Turns a normal condition into a stateful one

State controller

A controller is the rule for accumulation. It defines:

  • method: sum or count
  • window: rolling (seconds) or fixed (day|week|month)
  • partition_by: non-empty list of fields that split state into separate buckets

The aggregation attribute must resolve to a numeric value (e.g., amount, value). Non-numeric attributes make roll ups impossible and will cause the condition to immediately fail. See the Condition Fields table in the shared policy introduction for the complete list.

window config:

  • Rolling: { "type": "rolling", "seconds": <number> }
  • Fixed: { "type": "fixed", "interval": "day" | "week" | "month" }
  • Rolling windows evaluate over the trailing seconds for each partition. They start tracking as soon as the partition is first evaluated and continuously slide forward, so the stored total always reflects the most recent window span.
  • Fixed windows reset on calendar boundaries (UTC):
    • day intervals roll over at 00:00:00 UTC.
    • week intervals reset every Monday at 00:00:00 UTC.
    • month intervals anchor their window_start at the first day of the current month (00:00:00 UTC) and reset no earlier than the first day of the next month at 00:00:00 UTC.

State entry

A state entry is created lazily on first successful sign, then updated after each successful sign:

  • key: (controller_id, partition_key)
  • value: running numeric value
  • window_start: start time of current window

Missing entries are treated as 0 during evaluation.

Evaluation flow

Important behavior:

  • Evaluation is read-only.
  • State entries are updated only after successful signing.
  • DENY always overrides ALLOW.
  • Rolling windows begin tracking per partition when the entry is first evaluated and slide forward over the trailing interval as time advances.

Example 1: Rolling spend limit (sum)

Use method: "sum" + window.type: "rolling" for “last N seconds” controls.

  • Good for: “per-recipient spend over the last 24h”
  • Partition suggestion: recipient (abi:to) to isolate totals per target address

Create controller:

curl -sS -X POST "$WALLET_PROVIDER_BE_URL/v2/rest/createStateController" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"key_id": "alice_key_id",
"description": "Rolling 24h spend per recipient",
"method": "sum",
"window": { "type": "rolling", "seconds": 86400 },
"partition_by": [
{ "transaction_type": "erc20", "transaction_attr": "abi:to" }
]
},
"userSignatures": <...>
}'

Policy JSON:

{
"version": "1.0",
"description": "Rolling 24h USDC spend limit per recipient",
"rules": [
{
"description": "Allow if rolling spend (including current transfer) is <= 1000",
"issuer": [{ "type": "UserId", "id": "alice_user_id" }],
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"transaction_type": "erc20",
"transaction_attr": "receiver",
"operator": "eq",
"value": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
},
{
"transaction_type": "erc20",
"transaction_attr": "amount",
"operator": "lte",
"value": 1000,
"abi": {
"name": "transfer",
"type": "function",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "value", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}
  • The transaction_attr that drives aggregation here is amount which produce a numeric value. If it cannot be parsed as a number the controller cannot accumulate, and the condition will immediately fail.

Example 2: Calendar-day tx rate limit (count)

Use method: "count" + window.type: "fixed" and interval: "day" for “per UTC day” controls.

This fixed-day bucket always resets at 00:00:00 UTC. When the partition is first evaluated (e.g., Feb 14), its window_start is recorded as Feb 14 00:00:00 UTC, and the entry expires once the next evaluation hits that partition after Feb 15 00:00:00 UTC.

curl -sS -X POST "$WALLET_PROVIDER_BE_URL/v2/rest/createStateController" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"key_id": "alice_key_id",
"description": "Tx count per recipient per UTC day",
"method": "count",
"window": { "type": "fixed", "interval": "day" },
"partition_by": [
{ "transaction_type": "erc20", "transaction_attr": "abi:to" }
]
},
"userSignatures": <...>
}'

Policy JSON:

{
"version": "1.0",
"description": "Per-recipient tx count cap per UTC day",
"rules": [
{
"description": "Allow only if today's tx count (including current request) is <= 10",
"issuer": [{ "type": "UserId", "id": "alice_user_id" }],
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"transaction_type": "erc20",
"transaction_attr": "amount",
"operator": "lte",
"value": 10,
"abi": {
"name": "transfer",
"type": "function",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "value", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}

This policy reads as: “allow only if today’s count (including current request) is at most 10.”

Example 3: Mixed stateless + stateful policy

Pattern:

  • Rule A (DENY): block risky single-tx behavior (stateless), for example amount > 100.
  • Rule B (ALLOW): allow only if cumulative daily total stays within budget (stateful).

This gives tight protection because:

  • obvious bad requests are blocked immediately by Rule A
  • normal requests still must pass cumulative budget checks in Rule B

Create controller:

curl -sS -X POST "$WALLET_PROVIDER_BE_URL/v2/rest/createStateController" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"key_id": "alice_key_id",
"description": "Daily USDC spend per recipient (UTC day)",
"method": "sum",
"window": { "type": "fixed", "interval": "day" },
"partition_by": [
{ "transaction_type": "erc20", "transaction_attr": "abi:to" }
]
},
"userSignatures": <...>
}'

Policy JSON:

{
"version": "1.0",
"description": "Single-tx cap + daily spend cap (per recipient)",
"rules": [
{
"description": "Deny USDC transfer if amount > 100",
"issuer": [{ "type": "UserId", "id": "alice_user_id" }],
"action": "deny",
"chain_type": "ethereum",
"conditions": [
{
"transaction_type": "erc20",
"transaction_attr": "receiver",
"operator": "eq",
"value": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
},
{
"transaction_type": "erc20",
"transaction_attr": "amount",
"operator": "gt",
"value": 100,
"abi": {
"name": "transfer",
"type": "function",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "value", "type": "uint256" }
]
}
}
]
},
{
"description": "Allow USDC transfer only if today's cumulative spend (including current transfer) is <= 1000",
"issuer": [{ "type": "UserId", "id": "alice_user_id" }],
"action": "allow",
"chain_type": "ethereum",
"conditions": [
{
"transaction_type": "erc20",
"transaction_attr": "receiver",
"operator": "eq",
"value": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
},
{
"transaction_type": "erc20",
"transaction_attr": "amount",
"operator": "lte",
"value": 1000,
"abi": {
"name": "transfer",
"type": "function",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "value", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}

Update and reset rules

State controllers persist historical entries and are tightly coupled to the exact semantics of the stateful condition (how entries are partitioned, aggregated, and bounded). If you change those semantics, you MUST create a new controller and update the policy to reference the new state.id. Existing state entries are not migrated.

Recreate the controller if you change any of the following:

  • Bound condition fields (for example, transaction_type / transaction_attr)
  • partition_by
  • Window configuration (type or parameters)
  • Aggregation method (sumcount)

If you do not recreate the controller after changing semantics, the policy points to an incompatible state.id and the stateful condition becomes effectively invalid—every evaluation returns deny.

Operational sequence:

  1. Delete old controller.
  2. Create new controller.
  3. Update policy to reference new state.id.

Quick checklist

  • Start with one controller per one condition.
  • Keep partition_by non-empty and stable.
  • Treat missing partition fields as condition failure.
  • Expect missing entries to evaluate as 0.
  • Verify state.id is valid before rolling out policy updates.
  • Use the same auth model as policy management endpoints (signed payloads via userSignatures).

Next step