Imported from tetherto/wdk (
AGENTS.md). Install upstream withnpx skills add tetherto/wdk. Copyright stays with the author.
Agent Guide
This repository is part of the Tether WDK (Wallet Development Kit) ecosystem. It follows strict coding conventions and tooling standards to ensure consistency, reliability, and cross-platform compatibility (Node.js and Bare runtime).
Project Overview
- Architecture: Modular architecture with clear separation between Core, Wallet managers, and Protocols.
- Runtime: Supports both Node.js and Bare runtime.
Tech Stack & Tooling
- Language: JavaScript (ES2015+).
- Module System: ES Modules (
"type": "module"in package.json). - Type Checking: TypeScript is used purely for generating type declarations (
.d.ts). The source code remains JavaScript.- Command:
npm run build:types
- Command:
- Linting:
standard(JavaScript Standard Style).- Command:
npm run lint/npm run lint:fix
- Command:
- Testing:
jest(configured withexperimental-vm-modulesfor ESM support).- Command:
npm test
- Command:
- Dependencies:
cross-envis consistently used for environment variable management in scripts.
Coding Conventions
- File Naming: Kebab-case (e.g.,
wallet-manager.js). - Class Naming: PascalCase (e.g.,
WDK). - Private Members: Prefixed with
_(underscore) and explicitly documented with@private. - Imports: Explicit file extensions are mandatory (e.g.,
import ... from './file.js'). - Copyright: All source files must include the standard Tether copyright header.
Documentation (JSDoc)
Source code must be strictly typed using JSDoc comments to support the build:types process.
- Types: Use
@typedefto define or import types. - Methods: Use
@param,@returns,@throws. - Generics: Use
@template.
Development Workflow
- Install:
npm install - Lint:
npm run lint - Test:
npm test - Build Types:
npm run build:types
Key Files
index.js: Main entry point.bare.js: Entry point for Bare runtime optimization.src/: Core logic.types/: Generated type definitions (do not edit manually).
Repository Specifics
- Domain: Core Orchestrator.
- Role: Central entry point for the WDK. Manages lifecycle of multiple wallet instances, protocols, and transaction policies.
- Key Pattern: Dependency Injection (registerWallet, registerProtocol, registerPolicy).
- Architecture:
WDKclass manages a collection ofWalletManagerinstances and aPolicyEnginethat intercepts write-facing operations on every account returned fromgetAccount/getAccountByPath.
Policy Engine
- Source lives under
src/policy/. Public surface is thePolicyViolationErrorandPolicyConfigurationErrorclasses, theDEFAULT_POLICY_EXCLUSIONSandDENIAL_CODESconstants, and thePolicy*/SimulationResult/DenialCodetypedefs re-exported fromindex.js. Everything else undersrc/policy/is internal. - The engine wraps account methods and protocol getters at
getAccounttime. When any policy applies to an account ("governed"), the proxy wraps every callable on the account and its prototype chain except the names inDEFAULT_POLICY_EXCLUSIONS(src/policy/constants.js) unioned with the consumer'spolicyExclusions. Coverage is deny-by-default: an unknown method is governed, not skipped.docs/policy-exclusions-audit.mdis the source of truth for the default list — regenerate it (org endpoint, not/users/tetherto/repos, which hides private protocol packages) whenever a wallet or protocol package adds a public method. Combined with the default-deny evaluator (below) this closes sibling-method bypasses (e.g. a "cap transfer" policy cannot be sidestepped bysendTransaction({ to: token, data: ERC-20-transfer-calldata }),approveto an attacker, off-chain Permit viasignTypedData, or ERC-7702delegate). - Default-deny on governed accounts.
policy-evaluator.evaluate()returns BLOCK withreason: '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). - Every BLOCK verdict carries a
codefromDENIAL_CODES(src/policy/constants.js) —RULE_DENIED,NO_APPLICABLE_RULE, orGOVERNED_BUT_UNMATCHED— which surfaces asPolicyViolationError.codeand, for the same context, asSimulationResult.code. It is the stable discriminator;reasonholds consumer-authored rule text on theRULE_DENIEDpath and must not be switched on. The two default-deny codes are also whatpolicy-error.jskeys the explanatory message off: those errors carry the catch-all ALLOW snippet, whileRULE_DENIEDkeeps the tersePolicy violation: <policy>/<rule>: <reason>form. Adding a denial path means adding a code, not string-matchingreason.buildEnforcedMethodinpolicy-account-proxy.jsis the sole construction site ofPolicyViolationError; the verdict'scodeand the intercepted method name (operation) are threaded through there, so a new construction site elsewhere must thread both too. - Operation name = canonical method name. A rule's
operationis validated as any non-empty string, because any callable can be governed. A name that matches nothing still registers and never fires — but the real method stays governed, so a typo now fails safe (loud denial) instead of opening a hole. Adding a write method to a wallet package needs no change here; only adding a read does, and it belongs inDEFAULT_POLICY_EXCLUSIONSafter re-running the audit. - Two scopes only:
project(with optional wallet restriction declared via the policy'swalletfield) andaccount(with requiredwalletfield and a per-accountaccountslist of paths and/or integer indexes). Thewalletvalue is the same string passed toregisterWallet— an opaque consumer-chosen key, not a blockchain identifier.walletaccepts 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-scopeALLOWrules can short-circuit project-scope DENYs viaoverride_broader_scope: true. - Index-form
accountsentries match the index passed towdk.getAccount(wallet, index). They do not match accounts retrieved viagetAccountByPathbecause the wallet manager has no synchronous path → index resolver. Path-form entries match either retrieval style. - Nested-call escape works via a
Proxy(seesrc/policy/policy-account-proxy.js), not an async-context marker. The engine returns a Proxy that exposes enforced versions of write methods; the proxy'sgettrap binds non-enforced methods to the underlying account, so internalthis.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_registerProtocolsstep does installregisterProtocol/getXProtocolhelpers before the proxy is built — separate, pre-existing concern). The same code path runs on every JavaScript runtime that supportsProxy— noAsyncLocalStorage, no runtime detection, no Bare-specific fallback. - 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 storedthis._account(e.g.bridge.bridge(...)callingthis._account.sendTransaction(...)); this is the documented nested-call escape. proxy.registerProtocol(...)is special: the underlying helper returns the raw account, so thegettrap rewrites the return value to be the proxy itself. Without this rewrite,proxy.registerProtocol(...).sendTransaction(...)would skip enforcement.- Each condition is raced against
conditionTimeoutMs(default 30s, settable perregisterPolicycall viaRegisterPolicyOptions). The timeout is per-policy: it is resolved at registration time, clamped to the engine ceiling, and stored on the registry record, so registration order never changes the timeout another policy is evaluated under. The ceiling ismaxConditionTimeoutMs(default 30s), set once vianew WDK(seed, options); a policy requesting more is capped, not rejected. 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. - Conditions are user-supplied functions in Phase 1. The engine accepts
stateandonSuccessrule fields for Phase 2 (engine-managed state + post-execution hooks) but ignores them at runtime;stateis deep-cloned at registration so callers can't mutate engine state post-registration. - Tests live in
tests/wdk-policy.test.jsand exercise the engine exclusively through the public WDK API.