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", "seconds": 86400 },
"partition_by": [
{ "transaction_type": "erc20", "transaction_attr": "abi:to" }
]
},
"userSignatures": <...>
}'
- 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": "value", "type": "uint256" }
]
},
"state": {
"id": "<controller_id>",
"exclude_current": false
}
}
]
}
]
}
- Finally, transfer amounts for transactions that share the same
tovalue are accumulated in the samerollingwindow 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
| Component | What it is | Why it matters |
|---|---|---|
policy_state_controllers | Aggregation config (sum or count) with window and partition_by | Defines how to accumulate and slice state |
policy_state_entries | Runtime values keyed by (controller_id, partition_key) | Stores current total/count for one partition |
condition.state | Link 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:sumorcountwindow: 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
secondsfor 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):
dayintervals roll over at00:00:00 UTC.weekintervals reset every Monday at00:00:00 UTC.monthintervals anchor theirwindow_startat the first day of the current month (00:00:00 UTC) and reset no earlier than the first day of the next month at00: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.
DENYalways overridesALLOW.- 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_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: 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 (
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. - Use the same auth model as policy management endpoints (signed payloads via
userSignatures).
Next step
- Read State Controller Management for API references: Duo, Trio, Silent Network.