Skip to content

Receive the transaction policy engine from wdk-core - #2

Merged
jonathunne merged 11 commits into
mainfrom
feat/policy-engine-from-core
Nov 12, 2026
Merged

jonathunne merged 11 commits into
mainfrom
feat/policy-engine-from-core

Conversation

@AlonzoRicardo

@AlonzoRicardo AlonzoRicardo commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

The transaction policy engine moves here from @tetherto/wdk and publishes as @tetherto/wdk-policies, an opt-in plugin. Core's side, and the hook the plugin relies on, is tetherto/wdk#127, released in @tetherto/wdk 1.0.0-beta.23.

The first two commits are the copy and the packaging, so a reviewer can see that the engine was copied unchanged. Review fixes follow as small commits.

1. Copy the engine from core, unchanged

Cut from tetherto/wdk main at 467b58b, which was released unchanged as 1.0.0-beta.21, after every open engine fix had been decided (tetherto/wdk#117, tetherto/wdk#119, tetherto/wdk#120 and tetherto/wdk#121 merged; tetherto/wdk#111 closed as a duplicate):

  • src/policy/* → src/
  • types/src/policy/* → types/src/
  • tests/wdk-policy.test.js → tests/
  • docs/policy-exclusions-audit.md
  • core's repository scaffolding: LICENSE, .github/ (issue template, PR template, build and publish workflows), .npmrc, .npmignore, .editorconfig, .gitignore, tsconfig.json

2. Package it as a plugin

src/wdk-policies.js defines WdkPolicies:

import WDK from '@tetherto/wdk'
import WdkPolicies, { PolicyViolationError } from '@tetherto/wdk-policies'

const wdk = new WDK(seed).registerWallet('ethereum', WalletManagerEvm, config)

const policies = new WdkPolicies({ maxConditionTimeoutMs, policyExclusions })
  .attach(wdk, 'ethereum')
  .register(policy)

const account = await wdk.getAccount('ethereum', 0)
  • new WdkPolicies(options) takes the two settings core's constructor used to take: maxConditionTimeoutMs and policyExclusions.
  • attach(wdk, ...blockchains) registers one middleware per wallet. The middleware returns the engine's governed proxy. Attaching the same wallet twice installs one middleware.
  • register(policies, options) replaces wdk.registerPolicy. It throws PolicyConfigurationError for a policy bound to a wallet that was never attached.
  • getExclusions() replaces wdk.getPolicyExclusions().
  • dispose(blockchains?) replaces the policy half of wdk.dispose.

Exports and types:

  • index.js exports WdkPolicies as the default, plus the names core exported: PolicyViolationError, PolicyConfigurationError, DEFAULT_POLICY_EXCLUSIONS, DENIAL_CODES, and the policy typedefs.
  • bare.js is a Bare entry, as core has.
  • types/ holds the hand-maintained declarations, with types/src/wdk-policies.d.ts added.

Tests:

  • tests/legacy-wdk.js rebuilds core's old registerPolicy API on top of core plus the plugin, including core's order of applying policies after every middleware. The engine's suite then runs with only its import line changed. Porting the suite to WdkPolicies directly can come later.
  • tests/wdk-policies.test.js covers the plugin's own API.

package.json:

  • name @tetherto/wdk-policies, version 1.0.0-beta.1;
  • @tetherto/wdk ^1.0.0-beta.23 as a peer dependency, and pinned to 1.0.0-beta.23 as a dev dependency. 1.0.0-beta.22 and earlier must not satisfy the peer range: they call a middleware without the context argument, so getAccount() would throw;
  • zod pinned to 4.4.3, the version core's lockfile has. Two engine tests assert zod's exact error text, and zod 4.6 changed it. Loosening the pin means updating those two expectations;
  • bare-node-runtime ^1.5.2, for the Bare entry, as core has;
  • package-lock.json.

README.md and AGENTS.md carry the sections core had, rewritten for the plugin, with a migration table from @tetherto/wdk 1.0.0-beta.21. The README says up front that the engine runs in the caller's own process and is a guardrail against mistakes, not a security boundary.

Motivation and Context

The engine lived in @tetherto/wdk, so every core install shipped it and zod. As a plugin it is opt-in.

Attach order matters. A middleware registered after attach receives the governed account, which refuses new functions and accessors. The README says to attach after any middleware whose effects should be governed.

Changelog note

  • First release. The engine, its errors, constants and typedefs are the ones @tetherto/wdk 1.0.0-beta.21 shipped, except that waitForTransaction is now in DEFAULT_POLICY_EXCLUSIONS. It only polls getTransaction, which was already excluded, so a policy that allowed only sends denied waiting on its own transaction.
  • Requires @tetherto/wdk 1.0.0-beta.23 or later, where a middleware's return value replaces the account. A middleware that returns a value by accident now replaces the account too.

Verification

  • Against @tetherto/wdk 1.0.0-beta.23 from npm: 167 tests pass, the engine's 157 and the plugin's 10. standard is clean.
  • diff of tests/wdk-policy.test.js against core at 467b58b: the import line and the three pinned exclusion counts differ.
  • Every file in src/ and types/src/ except wdk-policies.js, wdk-policies.d.ts and constants.js (the waitForTransaction entry) is byte-identical to core's src/policy/ and types/src/policy/ at 467b58b.

Before the first publish

  • The npm name @tetherto/wdk-policies exists already, as a 0.0.0 placeholder.
  • The publish workflow copied from core uses npm trusted publishing (id-token: write) and a release environment. This repository needs to be added as a trusted publisher for the package, and the environment created.

Related Issue

Closes #1

Follow-up: #3, attach does not check that a wallet is registered. It needs a new public method on core, so it is out of scope here.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

From tetherto/wdk main at 467b58b: src/policy/* to src/,
types/src/policy/* to types/src/, tests/wdk-policy.test.js,
docs/policy-exclusions-audit.md, and the repository scaffolding (LICENSE,
.github, .npmrc, .npmignore, .editorconfig, .gitignore, tsconfig.json).
The next commit packages it.
WdkPolicies attaches to a WDK instance through registerMiddleware, whose
middlewares may return a replacement account from @tetherto/wdk
1.0.0-beta.21 on, and returns the engine's governed proxy for every account
retrieved from an attached wallet. register, getExclusions and dispose take
over registerPolicy, getPolicyExclusions and the policy half of dispose.

The engine's own suite runs unchanged except for its import line, through
tests/legacy-wdk.js, which rebuilds core's old API on core plus this package,
including core's order of applying policies after every middleware;
tests/wdk-policies.test.js covers the plugin API. zod is pinned to 4.4.3,
the version core's lockfile had: two tests assert zod's error text, which 4.6
changed. README and AGENTS.md carry the sections core had, with a migration
table.
- Pin @tetherto/wdk and @tetherto/wdk-wallet exactly in devDependencies;
  the peer range stays ^1.0.0-beta.21.
- tests/legacy-wdk.js keeps its middlewares in a Map.
- Rewrap the WdkPolicies documentation lines that ran past 120 columns,
  in the source and the declarations.
- AGENTS.md: the declarations in types/ are hand-maintained, and
  protocols registered with account.registerProtocol are built with the
  governed proxy, so their internal account calls are evaluated.
- README.md: name attach and dispose parameters blockchains, as the code
  does.
- One @throws per condition, descriptions without history notes, and
  parameter descriptions that say what each argument is for, in the
  source and the declarations.
- Import Policy and RegisterPolicyOptions from the engine's index.js.
- Plugin tests: a dummy account class, the condition mock at the top
  level and checked for its arguments, the denial message asserted, and
  two new cases: attaching several wallets at once, and dispose() with
  no arguments.
@darkhorse-core

Copy link
Copy Markdown

We maintain a WDK wallet module for RGB assets that runs in the browser (@darkhorse-wallet/wdk-wallet-rgb), and tried this branch together with tetherto/wdk#127 to see whether RGB accounts can be governed. Moving the engine into an opt-in package is a real win for us: core no longer ships zod, and the plugin bundles and runs in Chromium with nothing extra. A transfer cap behaved exactly as documented: over the cap → PolicyViolationError, under the cap → reaches the wallet, and simulate.transfer → DENY.

Two things we ran into, in case they are useful:

1. A module's own read methods are denied once any policy applies. With only a cap on transfer, every RGB read except getAddress fails with NO_APPLICABLE_RULE (getBtcBalance, listAssets, listTransfers, getStatus, …), so the wallet can no longer show a balance. DEFAULT_POLICY_EXCLUSIONS covers the reads of the official modules, which a third-party module can't join. policyExclusions works around it, but each app has to know every module's read surface. Since the engine already requires toReadOnlyAccount(), would it make sense to treat the methods of the read-only account as reads? Passing our read-only class's method names as policyExclusions made all of those reads pass. Happy to send a PR if this direction fits.

2. Peer range vs. the already-tagged 1.0.0-beta.21. main of tetherto/wdk is tagged v1.0.0-beta.21 without the middleware hook, so ^1.0.0-beta.21 admits a core where wdk.getAccount() throws TypeError: Cannot destructure property 'blockchain' of 'undefined' from applyPoliciesTo. Moving the hook to 1.0.0-beta.22 and the peer range with it would avoid that.

1.0.0-beta.21 was released with the engine still in core; it calls middlewares without the context argument, so the plugin cannot attach to it.
@AlonzoRicardo

Copy link
Copy Markdown
Contributor Author

Thanks for trying it in the browser with an RGB module, that's very useful.

1. Reads on third-party modules. Keeping the exclusions an explicit list was deliberate. Deriving them from the read-only class was considered and set aside when the list was built (tetherto/wdk#82), because class placement turned out not to be reliable evidence:

  • Some reads live only on the writable class. Spark's quotes and Lightning request lookups are examples, so a derivation would still deny them.
  • It would let each module's class layout decide what policies can't see. Anything placed on a read-only class would become ungoverned without anyone reviewing it.

That's why every default exclusion was classified on its documented behaviour (see docs/policy-exclusions-audit.md in this PR), and why other modules opt in through policyExclusions. For RGB, passing your read-only method names there, as you did, is the intended path. If maintaining that list gets painful for apps, we can look at making it easier in a separate issue once this lands.

2. Peer range. Right, thanks for catching it. 1.0.0-beta.21 was released from main with the engine still in core, so the move now targets 1.0.0-beta.22: tetherto/wdk#127 bumps to it, and this PR requires ^1.0.0-beta.22.

@darkhorse-core

Copy link
Copy Markdown

Thanks, both make sense. We'll export the RGB read-only method names from the module so apps can pass them straight to policyExclusions, and move our peer range to ^1.0.0-beta.22 once it's out.

Comment thread src/wdk-policies.js
this._attached.set(wdk, attached)

for (const blockchain of blockchains) {
this._wallets.add(blockchain)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A typo here isn't caught — register() validates against this same set, so attach(wdk, 'etherium') succeeds and the accounts come back ungoverned. Core checked against the wallets actually registered.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may require a new public method on core (e.g. wdk.hasWallet), which should be designed on its own rather than rushed in here, so let's leave it out of scope for this PR and we don't want to block on it. Tracking in #3.

Comment thread src/constants.js Resolved
It only polls getTransaction, which is already excluded, so a policy that allowed only sends denied waiting on its own transaction. Adds it to the default exclusions and the audit, and moves the pinned counts in the engine suite.
beta.22 has no middleware context or replacement; beta.23 is the first release with both.
@jonathunne
jonathunne dismissed quocle108’s stale review November 12, 2026 09:33

requested changes were made; attach validation is tracked in #3

@jonathunne
jonathunne merged commit 7f78e10 into main Nov 12, 2026
2 checks passed
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.

Receive the transaction policy engine from wdk-core

4 participants