Skip to content

Commit a00b391

Browse files
authored
Merge pull request #55 from tetherto/feat/local-transaction-policies-phase-1
feat: add Phase 1 local transaction policy engine
2 parents 4a5dc47 + d4d8b16 commit a00b391

31 files changed

Lines changed: 4570 additions & 25 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
node_modules
22
coverage
3+
.scripts
4+
.agents

AGENTS.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ Source code must be strictly typed using JSDoc comments to support the `build:ty
4444

4545
## Repository Specifics
4646
- **Domain:** Core Orchestrator.
47-
- **Role:** Central entry point for the WDK. Manages lifecycle of multiple wallet instances and protocols.
48-
- **Key Pattern:** Dependency Injection (registerWallet, registerProtocol).
49-
- **Architecture:** `WDK` class manages a collection of `WalletManager` instances.
47+
- **Role:** Central entry point for the WDK. Manages lifecycle of multiple wallet instances, protocols, and transaction policies.
48+
- **Key Pattern:** Dependency Injection (registerWallet, registerProtocol, registerPolicy).
49+
- **Architecture:** `WDK` class manages a collection of `WalletManager` instances and a `PolicyEngine` that intercepts write-facing operations on every account returned from `getAccount` / `getAccountByPath`.
50+
51+
## Policy Engine
52+
- Source lives under `src/policy/`. Public surface is the `PolicyViolationError` and `PolicyConfigurationError` classes plus the `Policy*` / `SimulationResult` typedefs re-exported from `index.js`. Everything else under `src/policy/` is internal.
53+
- The engine wraps account write methods and protocol getters at `getAccount` time. When any policy applies to an account ("governed"), the proxy wraps **every** method in `OPERATIONS` that exists on the underlying account — not just the ones referenced by registered rules. Combined with the default-deny evaluator (below) this closes sibling-method bypasses (e.g. a "cap transfer" policy cannot be sidestepped by `sendTransaction({ to: token, data: ERC-20-transfer-calldata })`, `approve` to an attacker, off-chain Permit via `signTypedData`, or ERC-7702 `delegate`).
54+
- **Default-deny on governed accounts.** `policy-evaluator.evaluate()` returns BLOCK with `reason: 'no-applicable-rule'` whenever an operation reaches it and no rule addresses that operation. To opt into permissive semantics on a specific account, register a wildcard ALLOW baseline (`operation: '*', action: 'ALLOW'`) and layer specific DENYs on top. Accounts with no registered policies are not governed and skip the proxy entirely (zero cost).
55+
- **Operation name = canonical IWalletAccount method name.** The engine wraps `account[op]` by string lookup, so every entry in `OPERATIONS` (`src/policy/constants.js`) must match the exact method name on `@tetherto/wdk-wallet`'s base `IWalletAccount` or a registered protocol class. A divergence silently no-ops the policy (registers fine, never fires) — which is worse than a hard error. When adding a new wallet method that emits a signature or moves value (e.g., `signTransaction`, `sign`), add it to `OPERATIONS` and the `PolicyOperation` typedef. Conversely, do not add aspirational entries that no wallet implements; the schema accepts them, but they produce silently-broken policies.
56+
- Two scopes only: `project` (with optional wallet restriction declared via the policy's `wallet` field) and `account` (with required `wallet` field and a per-account `accounts` list of paths and/or integer indexes). The `wallet` value is the same string passed to `registerWallet` — an opaque consumer-chosen key, not a blockchain identifier. `wallet` accepts a single string or a non-empty array of strings; omitting it on a project-scope policy applies it across every registered wallet. DENY wins across scopes; account-scope `ALLOW` rules can short-circuit project-scope DENYs via `override_broader_scope: true`.
57+
- Index-form `accounts` entries match the index passed to `wdk.getAccount(wallet, index)`. They do not match accounts retrieved via `getAccountByPath` because the wallet manager has no synchronous path → index resolver. Path-form entries match either retrieval style.
58+
- Nested-call escape works via a `Proxy` (see `src/policy/policy-account-proxy.js`), not an async-context marker. The engine returns a Proxy that exposes enforced versions of write methods; the proxy's `get` trap binds non-enforced methods to the underlying account, so internal `this.method()` calls and protocol code that holds a direct reference to the account naturally bypass enforcement. The policy engine itself does not mutate the account (the WDK's `_registerProtocols` step does install `registerProtocol` / `getXProtocol` helpers before the proxy is built — separate, pre-existing concern). The same code path runs on every JavaScript runtime that supports `Proxy` — no `AsyncLocalStorage`, no runtime detection, no Bare-specific fallback.
59+
- Enforcement applies to the **surface of the proxy** only. Underscore-prefixed fields (e.g. `protocol._account`) reach the raw account and bypass enforcement by design — they are private by convention and not part of the supported surface. The same is true for protocol methods that internally call account operations via their stored `this._account` (e.g. `bridge.bridge(...)` calling `this._account.sendTransaction(...)`); this is the documented nested-call escape.
60+
- `proxy.registerProtocol(...)` is special: the underlying helper returns the raw account, so the `get` trap rewrites the return value to be the proxy itself. Without this rewrite, `proxy.registerProtocol(...).sendTransaction(...)` would skip enforcement.
61+
- Each condition is raced against `conditionTimeoutMs` (default 30s, settable via `RegisterPolicyOptions`). A condition that throws or times out is fail-closed for DENY rules (treated as matched → block) and fail-open-as-no-match for ALLOW rules.
62+
- Conditions are user-supplied functions in Phase 1. The engine accepts `state` and `onSuccess` rule fields for Phase 2 (engine-managed state + post-execution hooks) but ignores them at runtime; `state` is deep-cloned at registration so callers can't mutate engine state post-registration.
63+
- Tests live in `tests/wdk-policy.test.js` and exercise the engine exclusively through the public WDK API.

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,74 @@ wdk.dispose()
5757
- **Multi-Chain Operations**: Coordinate balances, fee lookups, and transaction flows across registered chains
5858
- **Protocol Registration Support**: Attach swap, bridge, lending, fiat, and swidge protocols to registered blockchains
5959
- **Middleware Hooks**: Intercept account derivation with custom middleware
60+
- **Transaction Policies**: Local policy engine that intercepts write-facing operations and enforces user-defined ALLOW/DENY rules at project (global or wallet-bound) and account scopes — with simulation, nested-call handling, and structured `PolicyViolationError`s
6061
- **Seed Utilities**: Generate and validate BIP-39 seed phrases
6162
- **Selective Disposal**: Dispose specific registered wallets or clear the full WDK instance
6263

64+
## Transaction Policies
65+
66+
Register policies on a `WDK` instance to gate write-facing operations on every wallet account. Each registered rule can `ALLOW` or `DENY` an attempted operation based on a condition function; matching `DENY`s throw a `PolicyViolationError` before the underlying method runs.
67+
68+
```javascript
69+
import WDK, { PolicyViolationError } from '@tetherto/wdk'
70+
import WalletManagerEvm from '@tetherto/wdk-wallet-evm'
71+
72+
const wdk = new WDK(seedPhrase)
73+
.registerWallet('ethereum', WalletManagerEvm, { provider: '...' })
74+
.registerPolicy({
75+
id: 'value-cap',
76+
name: 'Cap value at 1 ETH',
77+
scope: 'project',
78+
rules: [{
79+
name: 'allow-under-1-eth',
80+
operation: 'sendTransaction',
81+
action: 'ALLOW',
82+
conditions: [({ params }) => BigInt(params.value) <= 10n ** 18n]
83+
}]
84+
})
85+
86+
const account = await wdk.getAccount('ethereum', 0)
87+
88+
try {
89+
await account.sendTransaction({ to: '0x…', value: 5n * 10n ** 18n })
90+
} catch (err) {
91+
if (err instanceof PolicyViolationError) {
92+
console.log(err.policyId, err.ruleName, err.reason)
93+
}
94+
}
95+
96+
// Run the same evaluation without executing the transaction.
97+
const result = await account.simulate.sendTransaction({ to: '0x…', value: 1n })
98+
// → { decision: 'ALLOW' | 'DENY', policy_id, matched_rule, reason, trace }
99+
```
100+
101+
Policies have two scopes — `project` and `account`. A project-scope policy applies globally by default, or only to the wallets named in its `wallet` field (`wallet: 'ethereum'` or `wallet: ['ethereum', 'ton']`). The `wallet` value is the same string passed to `registerWallet`. It might be a chain name like `"ethereum"`, but it could equally be `"treasury-cold"` or any label the consumer chose; the engine treats it as an opaque key. An account-scope policy must declare a `wallet` and targets specific accounts within it, identified by either derivation path (`accounts: ["0'/0/0"]`) or integer index (`accounts: [0, 1]`) — index entries match accounts retrieved via `wdk.getAccount(wallet, index)`; path entries match either retrieval style. Evaluation is narrowest-first with `DENY` winning across scopes. Account-scope `ALLOW` rules can opt into `override_broader_scope: true` to short-circuit broader policies for explicit exceptions (e.g., treasury accounts). Conditions can be sync or async and may carry user-owned state via closures. Templates (`@tetherto/wdk-policy-templates`) and a portal UI for editing policies are coming in later phases.
102+
103+
### Default-deny semantics
104+
105+
The engine is **default-deny on governed accounts**. As soon as any policy applies to an account, the engine wraps every method in `OPERATIONS` (the set of write-facing and signing primitives — `sendTransaction`, `signTransaction`, `transfer`, `approve`, `sign`, `signTypedData`, `signAuthorization`, `delegate`, `revokeDelegation`, and protocol methods like `swap`, `bridge`, `swidge`, etc.) on that account. Any call to a wrapped method whose operation is not addressed by an `ALLOW` rule throws `PolicyViolationError` with `reason: 'no-applicable-rule'`.
106+
107+
This is intentional: a "cap transfer at $100" policy must not be sidesteppable by `sendTransaction({ to: token, data: <ERC-20 transfer calldata> })`, `approve(spender, MAX)`, an off-chain `signTypedData` Permit, or an ERC-7702 `delegate` to an attacker contract. The engine closes those bypasses by treating any unaddressed money-movement op on a governed account as DENY.
108+
109+
If you want permissive semantics on a specific account (allow anything that isn't explicitly denied), register a wildcard ALLOW rule as a baseline and layer specific DENYs on top:
110+
111+
```javascript
112+
wdk.registerPolicy({
113+
id: 'permissive-baseline',
114+
scope: 'project',
115+
rules: [
116+
{ name: 'allow-all', operation: '*', action: 'ALLOW', conditions: [] },
117+
{ name: 'block-bad', operation: 'sendTransaction', action: 'DENY', conditions: [({ params }) => isSanctioned(params.to)] }
118+
]
119+
})
120+
```
121+
122+
Accounts that have **no** registered policies are not governed — the proxy is not applied, and method calls go straight to the underlying account at zero cost.
123+
124+
The engine wraps accounts through an ES `Proxy` so internal SDK code that uses `this.method()` naturally bypasses enforcement — nested-call escape (e.g. `bridge` internally calling `sendTransaction`) works without any async-context tracking. The same code path runs on every JavaScript runtime that supports `Proxy`, including Bare.
125+
126+
Policy enforcement applies to the **surface of the proxy** returned by `getAccount` / `getAccountByPath`. Reaching for underscore-prefixed fields (e.g. `protocol._account`) bypasses enforcement by design — treat them as private. The same applies to account-level operations invoked from inside a protocol's own methods (e.g. `bridge.bridge(...)` internally calling `this._account.sendTransaction(...)`), which is the documented nested-call escape; it lets protocols use the account they were constructed with without re-entering the engine on every internal step.
127+
63128
## Compatibility
64129

65130
- **WDK Wallet Modules** including EVM, Solana, TON, TRON, and Bitcoin integrations

index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,22 @@
1515
'use strict'
1616

1717
/** @typedef {import('./src/wdk.js').IWalletAccount} IWalletAccount */
18+
/** @typedef {import('@tetherto/wdk-wallet').IWalletAccountReadOnly} IWalletAccountReadOnly */
1819
/** @typedef {import('./src/wdk.js').FeeRates} FeeRates */
1920
/** @typedef {import('./src/wdk.js').MiddlewareFunction} MiddlewareFunction */
2021

2122
/** @typedef {import('./src/wallet-account-with-protocols.js').IWalletAccountWithProtocols} IWalletAccountWithProtocols */
2223

24+
/** @typedef {import('./src/policy/index.js').Policy} Policy */
25+
/** @typedef {import('./src/policy/index.js').PolicyRule} PolicyRule */
26+
/** @typedef {import('./src/policy/index.js').PolicyCondition} PolicyCondition */
27+
/** @typedef {import('./src/policy/index.js').PolicyContext} PolicyContext */
28+
/** @typedef {import('./src/policy/index.js').PolicyAction} PolicyAction */
29+
/** @typedef {import('./src/policy/index.js').PolicyScope} PolicyScope */
30+
/** @typedef {import('./src/policy/index.js').PolicyOperation} PolicyOperation */
31+
/** @typedef {import('./src/policy/index.js').SimulationResult} SimulationResult */
32+
/** @typedef {import('./src/policy/index.js').SimulationTraceEntry} SimulationTraceEntry */
33+
/** @typedef {import('./src/policy/index.js').RegisterPolicyOptions} RegisterPolicyOptions */
34+
2335
export { default } from './src/wdk.js'
36+
export { PolicyViolationError, PolicyConfigurationError } from './src/policy/index.js'

package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
},
2525
"dependencies": {
2626
"@tetherto/wdk-wallet": "^1.0.0-beta.9",
27-
"bare-node-runtime": "^1.4.0"
27+
"bare-node-runtime": "^1.4.0",
28+
"zod": "^4.4.3"
2829
},
2930
"devDependencies": {
3031
"cross-env": "7.0.3",

src/policy/constants.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright 2024 Tether Operations Limited
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
'use strict'
16+
17+
// Every entry must be the exact method name on `@tetherto/wdk-wallet`'s
18+
// IWalletAccount or an installed protocol class. The engine wraps methods by
19+
// looking them up on the account by name — a mismatch silently no-ops, which
20+
// is worse than a hard error. Audit upstream wallets before adding/removing.
21+
export const OPERATIONS = [
22+
'sendTransaction',
23+
'signTransaction',
24+
'transfer',
25+
'approve',
26+
'sign',
27+
'signTypedData',
28+
'signAuthorization',
29+
'delegate',
30+
'revokeDelegation',
31+
'swap',
32+
'bridge',
33+
'supply',
34+
'withdraw',
35+
'borrow',
36+
'repay',
37+
'buy',
38+
'sell',
39+
'swidge'
40+
]
41+
42+
export const WILDCARD = '*'
43+
44+
export const SCOPES = ['project', 'account']
45+
46+
export const ACTIONS = ['ALLOW', 'DENY']
47+
48+
export const PROTOCOL_METHODS = {
49+
swap: ['swap'],
50+
bridge: ['bridge'],
51+
lending: ['supply', 'withdraw', 'borrow', 'repay'],
52+
fiat: ['buy', 'sell'],
53+
swidge: ['swidge']
54+
}

src/policy/index.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2024 Tether Operations Limited
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
'use strict'
16+
17+
/**
18+
* Public surface of the policy engine sub-module.
19+
*
20+
* Re-exports the runtime classes (errors) and the public typedefs that
21+
* consumers need to type their policies, conditions, and simulation results.
22+
* Internal modules (registry, evaluator, validators, wrapper, context,
23+
* constants) are not re-exported here — they're implementation details.
24+
*/
25+
26+
/** @typedef {import('./policy-engine.js').Policy} Policy */
27+
/** @typedef {import('./policy-engine.js').PolicyRule} PolicyRule */
28+
/** @typedef {import('./policy-engine.js').PolicyCondition} PolicyCondition */
29+
/** @typedef {import('./policy-engine.js').PolicyContext} PolicyContext */
30+
/** @typedef {import('./policy-engine.js').PolicyAction} PolicyAction */
31+
/** @typedef {import('./policy-engine.js').PolicyScope} PolicyScope */
32+
/** @typedef {import('./policy-engine.js').PolicyOperation} PolicyOperation */
33+
/** @typedef {import('./policy-engine.js').SimulationResult} SimulationResult */
34+
/** @typedef {import('./policy-engine.js').SimulationTraceEntry} SimulationTraceEntry */
35+
/** @typedef {import('./policy-engine.js').RegisterPolicyOptions} RegisterPolicyOptions */
36+
37+
export { default as PolicyViolationError, PolicyConfigurationError } from './policy-error.js'

0 commit comments

Comments
 (0)