Skip to content

feat: introduce pluggable wallet policy engine (AOP-style)#36

Open
nimdeveloper wants to merge 23 commits intotetherto:developfrom
SemanticPay:main
Open

feat: introduce pluggable wallet policy engine (AOP-style)#36
nimdeveloper wants to merge 23 commits intotetherto:developfrom
SemanticPay:main

Conversation

@nimdeveloper
Copy link

Wallet Policies

This PR introduces Wallet Policies, a non-breaking feature that allows registering policy objects to gate mutating wallet, account, and protocol methods. Policies are evaluated before method execution. If a policy fails, a PolicyViolationError is thrown and execution is halted.

This feature is implemented using an Aspect-Oriented Programming (AOP) approach to cleanly separate cross-cutting concerns from wallet business logic.

Motivation and Context

WDK currently lacks a unified mechanism to enforce runtime constraints such as:

  • Transaction limits
  • Protocol-level restrictions
  • Blockchain-specific rules
  • Compliance or environment guardrails

These concerns are cross-cutting: they affect many unrelated methods across accounts and protocols. Without AOP, we would either duplicate checks inside every mutating method or make each class aware of external enforcement logic — both reduce maintainability and violate separation of concerns.

This PR introduces a lightweight AOP-style policy engine that allows defining enforcement logic once and declaratively applying it to selected wallet methods.

Aspect-Oriented Programming (AOP) Model

Join Point

A join point is a place where behavior can be intercepted. In WDK, every mutating method call is a join point:

await account.sendTransaction(tx)
await protocol.swap(options)

Pointcut

A pointcut defines which join points to target, expressed declaratively:

{ target: { blockchain: 'ethereum' }, method: 'sendTransaction' }

Pointcuts can scope by blockchain, protocol, method name, or all methods.

Advice

Advice is the code that runs at the join point via evaluate(). Advice is async-safe, enabling real-world enforcement tasks beyond simple in-memory checks — for example:

  • Querying an indexer to check cumulative spend over the past 24 hours
  • Calling a KYT (Know Your Transaction) service to screen a recipient address
// Simple synchronous check
evaluate({ params }) {
  return params.amount <= 1_000_000n
}

// Async: check rolling spend via indexer
async evaluate({ params, target }) {
  const spent = await indexer.getSpentLast24h(target.address)
  return spent + params.amount <= DAILY_LIMIT
}

// Async: screen address via KYT service
async evaluate({ params }) {
  const result = await kyt.screen(params.to)
  return result.risk !== 'high'
}

Advice runs before the original method. Policies execute sequentially and fail-fast — the first failing policy halts execution.

Aspect

An aspect combines a pointcut with advice. In this implementation, a policy object is an aspect:

const maxAmountPolicy = {
  name: 'max-amount',
  target: { blockchain: 'ethereum' },
  method: 'sendTransaction',
  evaluate({ params }) {
    return params.amount <= 1_000_000n
  }
}

Weaving

Weaving attaches aspects to join points at runtime inside _withPolicyGate():

const originalFn = instance[methodName].bind(instance)
instance[methodName] = async (...args) => {
  await runPolicies(methodPolicyMap.get(methodName), methodName, args[0], target)
  return originalFn(...args)
}

The original method is wrapped once, ensuring no modification to original class implementations and clean separation of concerns.

Design Decisions

Why not use Proxy?

Proxy intercepts all property access, complicates debugging, and makes execution flow less explicit. Instead, explicit method wrapping is used, which targets only specific methods, preserves performance, and keeps weaving controlled and predictable.

Why use Symbol?

Two internal Symbols store policy metadata per instance to prevent property name collisions, keep metadata non-enumerable, avoid polluting the public API, and ensure clean instance-level isolation.

Implementation Summary

  • registerPolicies() — validates and registers aspects (policies)
  • _runPolicies() — executes advice sequentially (async-safe, fail-fast)
  • _withPolicyGate() — performs runtime weaving
  • Policies support method, blockchain, and protocol scoping
  • Policies are isolated per account/protocol instance
  • Each method is wrapped only once

This change is fully backward compatible and introduces no breaking behavior.

Type of Change

  • New feature (non-breaking change which adds functionality)

jonathunne and others added 22 commits September 26, 2025 00:01
Co-authored-by: Chetas Murali <thephantomblu@gmail.com>
Co-authored-by: Chetas Murali <thephantomblu@gmail.com>
feat: rename to WDK, publish workflow
chore: public release, bump version
Co-authored-by: Jonathan Dunne <jonathanpdunne@gmail.com>
* style: format existing codes

* feat: add full list of mutating wallet and protocol methods

* feat: add PolicyViolationError class for handling policy rejection errors

* feat: add runPolicies function for sequential policy evaluation

* feat: implement policy registration and enforcement in WDK class

* feat: add registerPolicies function for policy management in WdkManager tests

* feat: update typescript types

* style: remove comments

* refactor: remove MUTATING_METHODS constant and dynamically gather methods from instance prototype

* refactor: remove MUTATING_METHODS constant types

* fix: update PolicyViolationError import and usage in tests

* refactor: improve Policy typedef documentation for clarity

* refactor: migrate runPolicies function to WDK class and update Policy typedefs

* refactor: update _withPolicyGate method signature to use generic type and improve parameter clarity

* refactor: standardize string quotes and improve Policy typedef documentation for clarity

* refactor: update PolicyTarget to use blockchain identifier and adjust related method signatures

* refactor: update PolicyViolationError to use PolicyTarget type for target parameter

* refactor: add registerPolicies document entries and revert format changes on README and test files for consistency

* refactor: remove unused runPolicies function from policies type definitions

* refactor: clean up whitespace and update class names for consistency in wdk-manager

* refactor: simplify policy evaluation by using mock functions in registerPolicies test

* refactor: update wallet methods and policies for transaction handling in WdkManager tests

* refactor: enhance policy evaluation and testing in WdkManager

* refactor: rename runPolicies to _runPolicies and introduce PolicyEvaluator type

* refactor: update return type of registerPolicies method to WdkManager
@jonathunne jonathunne changed the base branch from main to develop February 26, 2026 10:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants