WDK logoWDK documentation
Core SDKGuides

Transaction Policies

Register local ALLOW and DENY rules for WDK account and protocol write methods.

Use local transaction policies to evaluate account and protocol calls before execution. This guide covers registering policies, method coverage and migration, and simulating calls.

Use @tetherto/wdk v1.0.0-beta.17 or later for method exclusions and coverage beyond the earlier fixed operation list. The examples assume you have loaded an app-owned seedPhrase and registered the relevant wallet modules.

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 method runs.

ConceptWhat you configure
Scopescope: 'project' for project or wallet-level rules, or scope: 'account' for selected account indices or derivation paths
WalletOptional wallet bindings for project policies, required wallet bindings for account policies
RulesOrdered rules array evaluated before a governed account or protocol method runs
ActionALLOW to permit a matching governed call, or DENY to block it with PolicyViolationError
OperationThe wallet or protocol method a rule addresses, such as sendTransaction, transfer, swap, or *
ConditionsFunctions 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 allows all governed operations, then denies ETH sends above a local approval limit. Its wildcard ALLOW deliberately permits other methods, including newly discovered methods after an upgrade. Use explicit method names instead when your app needs a narrow permission set.

To register and exercise the send limit:

  1. Register the Ethereum wallet with its reviewed configuration.
  2. Register the rules through wdk.registerPolicy().
  3. Attempt the over-limit send and handle its policy denial.

You can enforce this local limit before sendTransaction() reaches the wallet:

Register A Local Send Limit
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: [
          ({ args }) => {
            const tx = args[0] as { value?: bigint } | undefined
            const value = tx?.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.

ScopeRequired fieldsApplies to
project without walletscope, rulesAll registered wallets
project with walletscope, wallet, rulesOne wallet identifier or a list of wallet identifiers
accountscope, wallet, accounts, rulesSpecific 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:

  1. If no registered policy applies to the account, WDK returns the original account. No policy proxy or simulate mirror is added.
  2. If at least one policy applies, the account is governed. WDK wraps its discovered public methods and registered protocol methods unless their names are in the exclusion set. See Supported Operations for the discovery boundary.
  3. If no rule addresses the attempted operation, WDK blocks the call with PolicyViolationError and reason: 'no-applicable-rule'.
  4. Account-scoped policies run before project-scoped policies. Within each scope, policies and rules run in registration order.
  5. A matching account-scoped DENY blocks immediately. A matching account-scoped ALLOW is recorded unless it has override_broader_scope: true.
  6. A matching account-scoped ALLOW with override_broader_scope: true allows the call immediately and skips project-scoped policies. This option is only valid on account-scoped ALLOW rules.
  7. Project-scoped rules run after account-scoped rules. A matching project-scoped DENY blocks. If no DENY matches and at least one ALLOW matched, WDK allows the call.
  8. 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():

Account-Level Exception
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: [
          ({ args }) => {
            const tx = args[0] as { value?: bigint } | undefined
            const value = tx?.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: [
          ({ args }) => {
            const tx = args[0] as { value?: bigint } | undefined
            const value = tx?.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

In beta.17, PolicyRule.operation accepts any non-empty method-name string, an array of names, or *. The following are examples, not a closed list:

OperationMethod family
sendTransactionNative transaction send
signTransactionTransaction signing without broadcast
transferToken transfer methods
approveToken allowance approvals
signMessage or payload signing
signTypedDataEIP-712 style typed-data signing
signAuthorizationAuthorization signing
delegateDelegation writes
revokeDelegationDelegation revocation
swapSwap protocol execution
bridgeBridge protocol execution
supply, withdraw, borrow, repayLending protocol writes
buy, sellFiat protocol writes
swidgeCombined swap and bridge route execution
createDepositAddress, renewDepositAddress, recoverDepositAddress, disableDepositAddressSmart Deposit Address writes
*Wildcard rule for all governed methods

Use the exact method name implemented by your wallet or protocol. For example, a rule for payLightningInvoice can now govern that Spark method. waitForTransaction() is also governed by default, so transaction polling needs a matching ALLOW rule on a governed account. A name that matches nothing still registers, but cannot allow the real call; a typo can leave that call denied. signMessage and signHash are accepted strings, but only affect methods with those names.

Coverage comes from public string-named methods declared directly on the object or inherited from its class. The proxy skips names in getPolicyExclusions(), constructor, protected members, symbol-named methods, and Object.prototype methods. Accessor-only properties are not intercepted; defining a getter does not add a governed method. An unfamiliar method is governed even when its name starts with get or quote. Governed methods return Promises; always await them even when the original method is synchronous.

Migrate From The Fixed Operation List

Default-deny evaluation already existed before beta.17. This release expands which methods reach it. A call that previously bypassed evaluation can now fail with reason: 'no-applicable-rule'.

  1. Compare the methods your app calls with the resolved exclusions from getPolicyExclusions().
  2. Register an explicit ALLOW rule for each additional operation your app intends to permit, then verify its simulation and denial cases.
  3. For a method that should bypass evaluation, review its behavior across every applicable wallet and protocol before adding a constructor exclusion. Exclusions apply globally by name and also remove policy simulation for that method.

You can inspect the resolved exclusions on an existing WDK instance:

Inspect Method Coverage
const exclusions = wdk.getPolicyExclusions()
console.log(exclusions.includes('getBalance')) // true by default
console.log(exclusions.includes('payLightningInvoice')) // false by default

Default exclusions cannot be removed. registerPolicy() rejects rules naming an excluded method because those rules could never run. Adding a wildcard ALLOW permits every governed method and is not a substitute for reviewing the expanded method surface.

Common Policy Patterns

WDK policies use JavaScript condition functions. Inspect the positional args array passed by the wallet or protocol method you are governing, then return true only when that rule should match. args[0] is the first argument, args[1] is the second, and so on.

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():

Address Allowlist
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: [
        ({ args }) => {
          const tx = args[0] as { to?: string } | undefined
          const to = tx?.to
          return typeof to === 'string' && allowedRecipients.has(to.toLowerCase())
        }
      ]
    }
  ]
})

You can require both a chain and value limit using wdk.registerPolicy():

Network And Value Gate
wdk.registerPolicy({
  id: 'ethereum-small-sends',
  name: 'Ethereum small sends',
  scope: 'project',
  wallet: 'ethereum',
  rules: [
    {
      name: 'allow-ethereum-small-send',
      operation: 'sendTransaction',
      action: 'ALLOW',
      conditions: [
        ({ args }) => {
          const tx = args[0] as { chainId?: number | string; value?: bigint } | undefined
          const value = tx?.value

          return String(tx?.chainId) === '1' &&
            typeof value === 'bigint' &&
            value <= 1000000000000000n
        }
      ]
    }
  ]
})

You can restrict typed-data signing to approved domains using wdk.registerPolicy():

Typed Data Domain Gate
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: [
        ({ args }) => {
          const typedData = args[0] as {
            domain?: { chainId?: number | string; verifyingContract?: string }
          } | undefined
          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():

Protocol Write Gate
wdk.registerPolicy({
  id: 'small-swaps-only',
  name: 'Small swaps only',
  scope: 'project',
  wallet: 'ethereum',
  rules: [
    {
      name: 'allow-small-swaps',
      operation: 'swap',
      action: 'ALLOW',
      conditions: [
        ({ args }) => {
          const swap = args[0] as { tokenInAmount?: bigint } | undefined
          const tokenInAmount = swap?.tokenInAmount

          return typeof tokenInAmount === 'bigint' &&
            tokenInAmount <= 1000000000n
        }
      ]
    }
  ]
})

Inspect Policy Context

Conditions receive a frozen PolicyContext:

FieldDescription
operationThe operation being evaluated
walletThe wallet identifier bound to the account
accountRead-only account view exposed by the wallet module
argsFrozen array of arguments in signature order; object values are cloned for evaluation

For governed calls, WDK clones object arguments before policy evaluation, then gives conditions a separate clone of that snapshot. The wallet receives the original snapshot, so later mutations to the caller's transaction object or the condition's copy do not change the forwarded values. The context and args array are frozen shallowly; nested objects are not frozen. Keep conditions free of mutations because conditions share their evaluation context.

PolicyContext.params was removed in @tetherto/wdk v1.0.0-beta.16. Replace params with args[0]. For multi-argument methods, use the actual signature position and check optional arguments before reading their fields. For example, a swidge(options, config?) condition reads options from args[0] and config from args[1].

Conditions can be synchronous or asynchronous. conditionTimeoutMs defaults to 30000 milliseconds and applies only to the policies registered by that registerPolicy(policies, options) call. Later registrations of distinct policy IDs do not change them. Registering the same policy ID again replaces the stored policy within its registry bucket and adopts the new registration's timeout. All project-scoped policies share one bucket; account-scoped policies are bucketed by wallet.

Set maxConditionTimeoutMs in the WDK constructor to cap every per-policy timeout for that instance. The ceiling also defaults to 30000 milliseconds; a larger requested conditionTimeoutMs is capped rather than rejected. Both timeout values must be finite positive numbers. 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 governed account and protocol methods, including methods outside the earlier fixed operation list. 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:

Dry-Run A Transaction Policy
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(...) or account.simulate.getSdaProtocol(label).createDepositAddress(...).

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, empty or non-string operation names, rules naming excluded methods, invalid condition functions or timeout options, missing account bindings, unknown wallet identifiers, or object arguments that cannot be cloned.

PolicyViolationError means a governed 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. On governed proxies, keyPair and string members beginning with _ are absent from direct property access, membership checks, own-property descriptors, and own-key enumeration. The proxy also refuses Object.preventExtensions() and Object.freeze() with TypeError so those hiding rules remain valid.
  • This is not a complete sandbox. Prototype inspection, separately retained raw account or protocol references, and nested calls made inside a module remain outside the proxy interception path.
  • The default exclusions contain specific read, quote, and lifecycle method names. A read or quote method absent from that list is governed until you explicitly exclude it; a get or quote prefix does not change coverage.
  • 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 with PolicyConfigurationError.
  • Pass plain data to governed methods. An object that cannot be structured-cloned, such as a plain object containing a callback, fails with PolicyConfigurationError. Class instances can become plain objects and lose their methods. A top-level function argument passes through unchanged in this release, so do not treat the snapshot as isolation for callback-driven state or side effects.
  • Method discovery is not a live monitor of account changes. Add module methods before obtaining a governed account; do not rely on mutating an account or its prototype after wrapping to update policy coverage.
  • Engine-managed state hooks are not active in this beta. The schema accepts state and onSuccess, but WDK does not pass state into conditions, update it after execution, persist it, or call onSuccess. Conditions can still use app-owned state through closures or external stores; keep that state outside rule.state, and have your app own durability, concurrency, and rollback behavior.

Next Steps


Need Help?

On this page