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.
- Create one state controller (aggregation config).
- Reference that controller in your policy condition (
state.id) and update the policy. - 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\",\"interval\":\"day\",\"count\":1}",
"partition_by": "[{\"transaction_type\":\"erc20\",\"transaction_attr\":\"abi:to\"}]"
},
"userSignatures": <...>
}'
window and partition_by are sent as JSON strings, not as nested JSON objects, because they are part of the signed payload. Responses return them as parsed objects.
- Then, use the returned
controller_idin 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": "amount", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}
- Finally, transfer amounts for transactions that share the same
tovalue are accumulated into the same rolling window. The window opens at the timestamp of the first accumulated transfer and closes 1 day later.
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
| Component | What it is | Why it matters |
|---|---|---|
| State Controller | Aggregation config (sum or count) with window and partition_by | Defines how to accumulate and slice state |
| State Entries | Result per request evaluation keyed by (controller_id, partition_key) | Stores current total/count for one partition |
condition.state | Links a Condition to a state controller by state.id | Turns a normal condition into a stateful one |
State controller
A controller is the rule for accumulation. It defines how value is accumulated over rolling window period and how value is partitioned:
-
method:sumorcount -
window: a rolling window, expressed asintervalxcount. Example:{
"type": "rolling",
"interval": "minute",
"count": 1
} -
partition_by: non-empty list of Condition Fields that split state into separate buckets
The aggregation from transaction attributes 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.
Rolling Window
| Field | Type | Required | Description |
|---|---|---|---|
type | "rolling" | Yes | Only supported window type |
interval | "minute" | "hour" | "day" | "week" | "month" | Yes | Unit of the window length |
count | positive integer | Yes | Number of interval units in the window |
The window length is count x interval. For example interval: "day", count: 3 is a rolling 3-day window, and interval: "hour", count: 12 is a rolling 12-hour window.
Rolling Window Mechanics
- Windows are not calendar aligned. A window opens at the exact timestamp of the first transaction that accumulates into the partition and closes
countxintervallater. A 1-day window opened by a transaction at14:37:12 UTCcloses the next day at14:37:12 UTC, not at midnight. - Windows are anchored, not continuously sliding. While a window is open, every new transaction adds to the same total. The first transaction that arrives at or after
window_start + count x intervalresets the previous window's accumulated value to0and re-anchorswindow_startto its own timestamp. monthis added as a calendar month (for example Mar 18 -> Apr 18), not as a fixed 30 days.
State entry
A state entry is the saved running result for one bucket of transactions (buckets are keyed by (controller_id, partition_key) ) in a
stateful policy.
For example, a 24-hour spend policy can keep a separate total for each recipient. After a request passes the policy check and is successfully signed, its amount is added to that recipient's entry. Later successful signs update the same entry during the current window.
If a bucket has no entry yet, the policy treats its previous total as 0 when
checking a request. Rejected or unsigned requests do not change the entry.
Evaluation flow
Important behavior:
- Evaluation is read-only.
- State entries are updated only after successful signing.
DENYalways overridesALLOW.- A window is anchored per partition when its entry is first created, and re-anchors (with the accumulated value reset to
0) on the first transaction that arrives after the window has closed.
Example 1: Rolling spend limit (sum)
Use method: "sum" with a rolling window for cumulative-amount controls.
- Good for: “per-recipient spend within a rolling 24h window”
- 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\",\"interval\":\"day\",\"count\":1}",
"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": "amount", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}
- The
transaction_attrthat drives aggregation here isamountwhich produce a numeric value. If it cannot be parsed as a number the controller cannot accumulate, and the condition will immediately fail.
Example 2: Rolling 24h tx rate limit (count)
Use method: "count" with interval: "day", count: 1 for “at most N transactions per 24h” controls.
The window is anchored to traffic, not to the clock: if the first counted transaction for a partition lands on Feb 14 at 09:12:30 UTC, that window closes on Feb 15 at 09:12:30 UTC. The next transaction at or after that instant starts a fresh window (count back to 1) anchored at its own timestamp.
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 rolling day",
"method": "count",
"window": "{\"type\":\"rolling\",\"interval\":\"day\",\"count\":1}",
"partition_by": "[{\"transaction_type\":\"erc20\",\"transaction_attr\":\"abi:to\"}]"
},
"userSignatures": <...>
}'
Policy JSON:
{
"version": "1.0",
"description": "Per-recipient tx count cap per rolling day",
"rules": [
{
"description": "Allow only if the rolling 24h 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": "amount", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}
This policy reads as: “allow only if the count inside the current rolling 24h window (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 the cumulative total in the rolling 24h window 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": "Rolling 24h USDC spend per recipient",
"method": "sum",
"window": "{\"type\":\"rolling\",\"interval\":\"day\",\"count\":1}",
"partition_by": "[{\"transaction_type\":\"erc20\",\"transaction_attr\":\"abi:to\"}]"
},
"userSignatures": <...>
}'
Policy JSON:
{
"version": "1.0",
"description": "Single-tx cap + rolling 24h 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": "amount", "type": "uint256" }
]
}
}
]
},
{
"description": "Allow USDC transfer only if the rolling 24h 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": "amount", "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 (
intervalorcount) - Aggregation method (
sum↔count)
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:
- Delete old controller.
- Create new controller.
- Update policy to reference new
state.id.
Quick checklist
- Start with one controller per one condition.
- Keep
partition_bynon-empty and stable. - Treat missing partition fields as condition failure.
- Expect missing entries to evaluate as
0. - Verify
state.idis valid before rolling out policy updates.
Next step
- Read State Controller Management for API references: Duo, Trio, Silent Network.