Transaction Policies
Register local ALLOW and DENY rules for WDK account and protocol write methods.
Local transaction policies let a WDK app evaluate rules before account or protocol write methods execute. Use them for local approval limits, account-level exceptions, preflight checks, or UI flows that need a dry-run verdict before calling a wallet method.
Transaction policies are local pre-execution controls. They do not enforce rules on-chain, replace smart-contract permissions, or validate live token metadata, balances, prices, or contract state.
Policy Structure
A transaction policy is a named local configuration object registered with wdk.registerPolicy(). Each policy chooses where it applies, then evaluates its ordered rules array before a governed account or protocol write method runs.
| Concept | What you configure |
|---|---|
| Scope | scope: 'project' for project or wallet-level rules, or scope: 'account' for selected account indices or derivation paths |
| Wallet | Optional wallet bindings for project policies, required wallet bindings for account policies |
| Rules | Ordered rules array evaluated before a governed account or protocol write method runs |
| Action | ALLOW to permit a matching governed call, or DENY to block it with PolicyViolationError |
| Operation | The wallet or protocol write operation a rule addresses, such as sendTransaction, transfer, swap, or * |
| Conditions | Functions that receive PolicyContext and return truthy when the rule should match |
Use scope: 'project' for rules that apply across all wallets or selected wallet identifiers. Use scope: 'account' with wallet and accounts for rules that apply only to specific account indices or derivation paths.
Each rule addresses one operation, multiple operations, or *. A matching ALLOW can permit the governed call, while a matching DENY blocks the call with PolicyViolationError.
WDK does not manage durable policy state for you. Conditions can inspect the current PolicyContext and app-owned inputs, including in-memory or externally stored state, but WDK does not persist rule.state, update counters, or run onSuccess hooks. Keep app-owned state outside rule.state; that field is reserved for future runtime semantics.
Register Policies
Register wallets before policies. wdk.registerPolicy() validates wallet bindings synchronously and throws PolicyConfigurationError if a policy references a wallet identifier that has not been registered.
The example below allows normal operations, then denies ETH sends above a local approval limit. The wildcard ALLOW rule is intentional: once a policy governs an account, wrapped write operations are default-denied unless a matching ALLOW permits them.
import WDK, { PolicyViolationError } from '@tetherto/wdk'
const wdk = new WDK(seedPhrase)
.registerWallet('ethereum', WalletManagerEvm, ethereumWalletConfig)
.registerPolicy({
id: 'eth-local-send-limit',
name: 'ETH local send limit',
scope: 'project',
wallet: 'ethereum',
rules: [
{
name: 'allow-normal-operations',
operation: '*',
action: 'ALLOW',
reason: 'Default local approval',
conditions: [() => true]
},
{
name: 'deny-large-eth-send',
operation: 'sendTransaction',
action: 'DENY',
reason: 'Amount exceeds the local approval limit',
conditions: [
({ params }) => {
const value = (params as { value?: bigint } | null)?.value
return typeof value === 'bigint' && value > 1000000000000000000n
}
]
}
]
})
const account = await wdk.getAccount('ethereum', 0)
try {
await account.sendTransaction({
to: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
value: 2000000000000000000n
})
} catch (error) {
if (error instanceof PolicyViolationError) {
console.error(error.reason)
}
}Scope Policies
Policies can target a whole project, selected wallets, or selected accounts.
| Scope | Required fields | Applies to |
|---|---|---|
project without wallet | scope, rules | All registered wallets |
project with wallet | scope, wallet, rules | One wallet identifier or a list of wallet identifiers |
account | scope, wallet, accounts, rules | Specific account indices or derivation paths for one wallet |
Account entries can be non-negative account indices or derivation-path strings. Index entries match accounts returned by getAccount(wallet, index). Path entries match accounts returned by path-based retrieval.
Evaluation Rules
WDK evaluates policies in this order:
- If no registered policy applies to the account, WDK returns the original account. No policy proxy or
simulatemirror is added. - If at least one policy applies, the account is governed. WDK wraps every supported write or signing operation that exists on that account, plus registered protocol write methods.
- If no rule addresses the attempted operation, WDK blocks the call with
PolicyViolationErrorandreason: 'no-applicable-rule'. - Account-scoped policies run before project-scoped policies. Within each scope, policies and rules run in registration order.
- A matching account-scoped
DENYblocks immediately. A matching account-scopedALLOWis recorded unless it hasoverride_broader_scope: true. - A matching account-scoped
ALLOWwithoverride_broader_scope: trueallows the call immediately and skips project-scoped policies. This option is only valid on account-scopedALLOWrules. - Project-scoped rules run after account-scoped rules. A matching project-scoped
DENYblocks. If noDENYmatches and at least oneALLOWmatched, WDK allows the call. - If rules addressed the operation but none matched, WDK blocks with
reason: 'governed-but-unmatched'.
Conditions run in array order and every condition must return truthy for the rule to match. If an ALLOW condition throws or times out, WDK treats that rule as unmatched. If a DENY condition throws or times out, WDK blocks the call.
You can create an account-scoped exception using wdk.registerPolicy():
wdk.registerPolicy([
{
id: 'project-send-limit',
name: 'Project send limit',
scope: 'project',
wallet: 'ethereum',
rules: [
{
name: 'allow-normal-operations',
operation: '*',
action: 'ALLOW',
conditions: [() => true]
},
{
name: 'deny-large-send',
operation: 'sendTransaction',
action: 'DENY',
reason: 'Project send limit exceeded',
conditions: [
({ params }) => {
const value = (params as { value?: bigint } | null)?.value
return typeof value === 'bigint' && value > 1000000000000000n
}
]
}
]
},
{
id: 'treasury-account-override',
name: 'Treasury account override',
scope: 'account',
wallet: 'ethereum',
accounts: [0],
rules: [
{
name: 'allow-treasury-sends',
operation: 'sendTransaction',
action: 'ALLOW',
override_broader_scope: true,
reason: 'Treasury account has a higher local approval limit',
conditions: [
({ params }) => {
const value = (params as { value?: bigint } | null)?.value
return typeof value === 'bigint' && value <= 10000000000000000n
}
]
}
]
}
])In the example above, the treasury account can send up to 0.01 ETH because the account-scoped ALLOW rule matches and skips the project-scoped limit. If the account rule does not match, project-scoped rules still run and can block the call.
Supported Operations
Use these operation names in PolicyRule.operation:
| Operation | Method family |
|---|---|
sendTransaction | Native transaction send |
signTransaction | Transaction signing without broadcast |
transfer | Token transfer methods |
approve | Token allowance approvals |
sign | Message or payload signing |
signTypedData | EIP-712 style typed-data signing |
signAuthorization | Authorization signing |
delegate | Delegation writes |
revokeDelegation | Delegation revocation |
swap | Swap protocol execution |
bridge | Bridge protocol execution |
supply, withdraw, borrow, repay | Lending protocol writes |
buy, sell | Fiat protocol writes |
swidge | Combined swap and bridge route execution |
* | Wildcard rule for all wrapped write operations |
Use sign for message-style signing in this release. signMessage and signHash are not valid PolicyOperation values.
Common Policy Patterns
WDK policies use JavaScript condition functions. Inspect the params and args passed by the wallet or protocol method you are governing, then return true only when that rule should match.
WDK does not fetch prices, decode calldata, maintain address lists, or manage durable policy state for you. Keep app-owned inputs current, and handle persistence and concurrency when a condition depends on counters or cumulative limits.
You can allow sends to approved recipients using wdk.registerPolicy():
const allowedRecipients = new Set([
'0x71C7656EC7ab88b098defB751B7401B5f6d8976F'.toLowerCase()
])
wdk.registerPolicy({
id: 'approved-recipients',
name: 'Approved recipients',
scope: 'project',
wallet: 'ethereum',
rules: [
{
name: 'allow-approved-send',
operation: 'sendTransaction',
action: 'ALLOW',
conditions: [
({ params }) => {
const to = (params as { to?: string } | null)?.to
return typeof to === 'string' && allowedRecipients.has(to.toLowerCase())
}
]
}
]
})You can require both a chain and value limit using wdk.registerPolicy():
wdk.registerPolicy({
id: 'base-small-sends',
name: 'Base small sends',
scope: 'project',
wallet: 'ethereum',
rules: [
{
name: 'allow-base-small-send',
operation: 'sendTransaction',
action: 'ALLOW',
conditions: [
({ params }) => {
const tx = params as { chainId?: number | string; value?: bigint } | null
const value = tx?.value
return String(tx?.chainId) === '8453' &&
typeof value === 'bigint' &&
value <= 1000000000000000n
}
]
}
]
})You can restrict typed-data signing to approved domains using wdk.registerPolicy():
const approvedTypedDataDomains = new Set([
'1:0x000000000022d473030f116ddee9f6b43ac78ba3'
])
wdk.registerPolicy({
id: 'approved-typed-data-domains',
name: 'Approved typed data domains',
scope: 'project',
wallet: 'ethereum',
rules: [
{
name: 'allow-approved-typed-data-domain',
operation: 'signTypedData',
action: 'ALLOW',
conditions: [
({ params }) => {
const typedData = params as {
domain?: { chainId?: number | string; verifyingContract?: string }
} | null
const verifyingContract = typedData?.domain?.verifyingContract
const domainKey = `${typedData?.domain?.chainId}:${verifyingContract}`.toLowerCase()
return typeof verifyingContract === 'string' &&
approvedTypedDataDomains.has(domainKey)
}
]
}
]
})You can gate protocol write methods by their method parameters using wdk.registerPolicy():
wdk.registerPolicy({
id: 'small-swaps-only',
name: 'Small swaps only',
scope: 'project',
wallet: 'ethereum',
rules: [
{
name: 'allow-small-swaps',
operation: 'swap',
action: 'ALLOW',
conditions: [
({ params }) => {
const swap = params as { tokenInAmount?: bigint } | null
const tokenInAmount = swap?.tokenInAmount
return typeof tokenInAmount === 'bigint' &&
tokenInAmount <= 1000000000n
}
]
}
]
})Inspect Policy Context
Conditions receive a frozen PolicyContext:
| Field | Description |
|---|---|
operation | The operation being evaluated |
wallet | The wallet identifier bound to the account |
account | Read-only account view exposed by the wallet module |
params | The first argument passed to the wrapped method |
args | All arguments passed to the wrapped method |
For governed write calls, WDK snapshots the method arguments once before policy evaluation. Conditions see that snapshot, and WDK forwards the same approved values to the underlying wallet method. This prevents a caller from mutating a transaction object while an asynchronous policy condition is running.
Conditions can be synchronous or asynchronous. conditionTimeoutMs defaults to 30000 milliseconds and can be set through registerPolicy(policies, options). If a DENY condition throws or times out, WDK blocks the call. If an ALLOW condition throws or times out, WDK treats that allow rule as unmatched.
Simulate Before Execution
When a policy applies to an account, WDK adds runtime simulate mirrors for wrapped account and protocol write methods. Simulation returns the policy verdict and does not call the underlying wallet or protocol method.
You can dry-run a governed account method through the runtime simulate mirror:
const account = await wdk.getAccount('ethereum', 0)
const result = await (account as any).simulate.sendTransaction({
to: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
value: 2000000000000000n
})
console.log(result.decision, result.reason, result.trace)Simulation results include decision, policy_id, matched_rule, reason, and trace. Protocol write methods are also mirrored, for example account.simulate.getSwapProtocol(label).swap(...).
In this beta, simulate is added at runtime but is not typed on the account return type. Use a local helper interface or a narrow as any cast at the call site.
Handle Errors
PolicyConfigurationError means WDK rejected the policy setup or could not safely evaluate a governed call. Common causes include invalid scopes, actions, operation names, condition functions, timeout options, missing account bindings, wallet identifiers that have not been registered, or governed method arguments that are not structured-cloneable.
PolicyViolationError means an enforced write call was blocked by a matching DENY rule or by default-deny when no ALLOW rule matched. Catch it around the write call and surface the reason to the user or approval workflow.
Runtime Caveats
- Policies wrap the WDK account/protocol proxy surface. Private fields, underscore methods, protocol internals, or nested calls made inside a module can bypass local policy evaluation.
- Quote and read methods are not wrapped. Policies apply to write methods such as sends, signs, swaps, bridges, lending writes, fiat writes, and swidge execution.
- Policy conditions receive local method arguments. WDK does not decode calldata, fetch prices, validate token metadata, or calculate fiat value unless your condition function does that work.
- Wallet accounts must expose a read-only account view when a policy applies, otherwise
getAccount()fails withPolicyConfigurationError. - Governed write-call arguments must be structured-cloneable, such as primitives, plain objects, arrays,
bigint, and typed arrays. Functions, live class instances, and other non-cloneable values fail closed withPolicyConfigurationErrorinstead of being forwarded unsafely. - Engine-managed state hooks are not active in this beta. The schema accepts
stateandonSuccess, but WDK does not passstateinto conditions, update it after execution, persist it, or callonSuccess. Conditions can still use app-owned state through closures or external stores; keep that state outsiderule.state, and have your app own durability, concurrency, and rollback behavior.
Next Steps
- Review
registerPolicy()in the API reference. - Use Send Transactions for base account send flows.
- Use Protocol Integration for swap, bridge, lending, fiat, and swidge method setup.