wdk-wrapper-agentguard
v1.0.0-beta.1
Published
WDK middleware wrapper for self-custodial AI agent payments: deterministic local policy enforcement and signed receipts for any WDK wallet.
Maintainers
Readme
wdk-wrapper-agentguard
Local, deterministic policy enforcement and signed receipts for self-custodial AI agent payments — for any WDK wallet.
Every autonomous agent needs spending limits and an audit trail. AgentGuard is one
registerMiddlewarecall that gives any WDK wallet both — same policy, every chain, every protocol module, every receipt cryptographically signed.
wdk-wrapper-agentguard is a WDK middleware module. It plugs into the official @tetherto/wdk orchestrator via registerMiddleware and decorates every wallet account with a deterministic policy engine. Every authorize / deny decision produces an append-only, ed25519-signed receipt that is independently verifiable.
It is chain-agnostic: works with EVM, TON, TRON, Solana, BTC, Spark, ERC-4337, or any future WDK wallet manager that exposes the standard IWalletAccount interface.
Status: beta. Built for the Tether WDK Module bounty (Mar 2026). Tested with
brittle, Bare-runtime compatible.
Use cases in the agentic economy: see
USE_CASES.mdfor the long form (agentic commerce / x402, A2A payments, delegated treasury, DeFi auto-pilot, subscription agents, kill-switch, receipts as a settlement primitive).
Why a wrapper / middleware module?
WDK ships five module types — wallet, swap, bridge, lending, fiat — and a registerMiddleware hook for cross-cutting concerns (e.g., the official @tetherto/wdk-wrapper-failover-cascade). AgentGuard fills a gap none of the existing modules cover: a portable authorization layer for autonomous agents. No core changes, no per-chain forks — one policy, every wallet.
Architecture
┌─────────────────────────────┐
│ Agent loop / LLM caller │
│ (orchestrator, MCP, x402, │
│ yield bot, ops bot, …) │
└──────────────┬─────────────┘
│ account.transfer(…)
│ account.sendTransaction(…)
▼
┌─────────────────────────────┐ ┌──────────────────┐
│ AgentGuard │◄─load──│ policy.json │
│ (wdk-wrapper-agentguard) │ │ (deterministic, │
│ │ │ hot-swappable) │
│ 1. validateIntent │ └──────────────────┘
│ • caps / denylist / │
│ rate / chain │ ┌──────────────────┐
│ 2. record signed receipt │──sign─►│ Receipt store │
│ (ed25519 + policyHash) │ │ (allow + deny, │
│ 3. allow → forward │ │ verifiable │
│ deny → throw │ │ off-chain) │
└──────────────┬─────────────┘ └──────────────────┘
│ forwards only on allow
▼
┌─────────────────────────────┐
│ @tetherto/wdk wallet │ EVM │ TON │ SOL │ BTC │ …
│ IWalletAccount │
└──────────────┬─────────────┘
│ signed tx → RPC
▼
┌────────────┐
│ blockchain │
└────────────┘Protocol composition. Every official WDK protocol module —
@tetherto/wdk-protocol-bridge-usdt0-evm, @tetherto/wdk-protocol-lending-aave-evm,
swaps, etc. — calls this._account.sendTransaction(…) under the hood.
AgentGuard wraps sendTransaction on the account, so every protocol
call flows through the same policy gate without any protocol-specific code:
bridge.send(…) ┐
lending.deposit(…) ├─► account.sendTransaction(…) ─► AgentGuard ─► wallet ─► RPC
swap.exec(…) ┘See examples/integration.composed.js for a live demo with a
MockBridgeProtocol mirroring the real USDT0 bridge call shape.
Highlights
- WDK-native integration. Single
registerMiddleware(chain, fn)call. - Deterministic local policy engine. No network, no oracles, no LLMs in the decision path.
- Signed receipts. Every allow/deny is recorded as an ed25519-signed JSON receipt with a stable SHA-256 policy hash. Verifiable offline, after the fact, by anyone who has the public key.
- Rolling-window rules. Daily caps and per-hour transaction rate limits are evaluated against the in-memory history of approved operations.
- Fail-closed. Any thrown error blocks the underlying transaction. The wrapped account never reaches the wire on a denial.
- Bare-runtime compatible. Tests under
brittle-bare, runtime entry point inbare.js. - Zero coupling to a chain. Works with
@tetherto/wdk-wallet-evm,@tetherto/wdk-wallet-ton,@tetherto/wdk-wallet-solana,@tetherto/wdk-wallet-spark, ERC-4337 smart accounts, gasless TON, or any future WDK wallet manager.
Live proof on Polygon Amoy
This module is exercised end-to-end against real @tetherto/wdk +
@tetherto/wdk-wallet-evm on Polygon Amoy testnet (chainId 80002).
- Funded test wallet:
0x46Ca9120Ea33E7AF921Db0a230831CB08AeB2910(derived through@tetherto/wdk-wallet-evmfrom a BIP-39 mnemonic in.env) - Outbound recipient:
0xde5797A9C474D4480f0769c9a1361001ED4f8038(configurable viaRECIPIENT=0x...) - Live signed broadcasts (status
0x1):0x84324fba…39c47(integration.evm.jsoutbound 1 wei → dev-2; receipt-capture run)0xe8385eea…705da(integration.evm.jsoutbound 1 wei → dev-2)0xc0bdd885…3e7b7(integration.composed.jsmock-bridge call, gas 24,240 with calldata)
- Three signed receipts captured under
submission/live-amoy-receipts.json(one allow + two deny). All share the samepolicyHashc5e78fdef9aea3c91c7a66061ce45f6ffa235dbd68d020b87e3c0edbac4aa2bc. - Independent verification:
The verifier depends only onnpm run verify:receipts # OK ... deny sendTransaction → 0xdead… amount=1 # OK ... deny sendTransaction → 0x...0001 amount=10¹18 # OK ... allow sendTransaction → 0xde57… amount=1 # Verified 3 receipts, 0 failed.@noble/ed25519+@noble/hashes, can be pasted into any environment, and rejects any tampered field.
See submission/LIVE-AMOY-PROOF.md for the
full breakdown.
Installation
npm install wdk-wrapper-agentguardPeer dependency:
@tetherto/wdk-wallet >= 1.0.0-beta.4.
Quick start
import WDK from '@tetherto/wdk'
import WalletManagerEvm from '@tetherto/wdk-wallet-evm'
import getAgentGuardMiddleware, { loadPolicy } from 'wdk-wrapper-agentguard'
const policy = await loadPolicy('./policy.json')
const { middleware, signer, stores } = getAgentGuardMiddleware({ policy })
const wdk = new WDK(seedPhrase)
.registerWallet('ethereum', WalletManagerEvm, { provider: process.env.RPC_URL })
.registerMiddleware('ethereum', middleware)
const account = await wdk.getAccount('ethereum')
// Allowed: transfer goes through, signed receipt recorded.
await account.transfer({ token: USDT, recipient: ALICE, amount: 1_000_000n })
// Denied: throws PolicyDeniedError, transaction never sent.
try {
await account.transfer({ token: USDT, recipient: BAD_ACTOR, amount: 1n })
} catch (err) {
console.log(err.reasons) // [{ code: 'RECIPIENT_DENIED', message: ... }]
console.log(err.receipt) // signed deny receipt
}
// Receipts for any account by address:
const myReceipts = stores.get(await account.getAddress()).list()Try the demo
Three examples ship with the repo, from zero-setup stub to real testnet integration.
1. Zero-setup stub demo
npm install
npm run exampleUses a stub IWalletAccount (no RPC, no real keys). Walks through allow / deny / cap-exceeded / denylisted scenarios and prints the signed receipt trail. Best for a quick first look.
2. Real @tetherto/wdk on Polygon Amoy testnet
cp .env.example .env
# Generate a fresh test mnemonic — never reuse a real seed:
node -e "import('@tetherto/wdk').then(m => console.log(m.default.getRandomSeedPhrase(12)))"
# Paste the mnemonic into .env as MNEMONIC=...
npm run example:integrationWhat this proves:
- AgentGuard registers via the real
wdk.registerMiddlewarehook on@tetherto/[email protected]. - Real account derivation through
@tetherto/[email protected]. - Live RPC calls (
getBalance) work againsthttps://rpc-amoy.polygon.technology. - Denied transactions are blocked locally — they never reach the RPC.
- Allowed transactions are signed and broadcast for real when you set
SEND_REAL_TX=1and fund the derived address from https://faucet.polygon.technology.
Sample run (without funding, allowed-broadcast skipped):
Derived account address: 0x8c1ecCb1e0EBD9167A8Ad41DfbcdCE8DCb5a714e
Live native balance: 0 wei
>> Scenario 1: send 1 wei to a denylisted address
✓ DENIED locally (no RPC call) reasons=[RECIPIENT_DENIED]
>> Scenario 2: send 1 MATIC (over cap) to allowed address
✓ DENIED locally (no RPC call) reasons=[AMOUNT_EXCEEDS_PER_TX, AMOUNT_EXCEEDS_DAILY_CAP]3. Composition with WDK protocol modules (bridge / lending / swap)
npm run example:composedThe killer feature: every official protocol module — including @tetherto/wdk-protocol-bridge-usdt0-evm and @tetherto/wdk-protocol-lending-aave-evm — funnels its on-chain calls through account.sendTransaction(...). AgentGuard wraps that method on the WDK account itself, so the moment a protocol object holds a wrapped account, every call it makes goes through the policy gate. No protocol-specific code in AgentGuard.
This script demonstrates that with a tiny MockBridgeProtocol whose call shape mirrors the real bridge module (this._account.sendTransaction(oftTx)):
Wrapped WDK account: 0x0184Eb87...
>> Bridge attempt 1: 1 wei, allowed recipient
policy allowed (broadcast attempted, fails only on insufficient funds — expected)
>> Bridge attempt 2: 1 wei, DENIED recipient
✓ Bridge call BLOCKED by AgentGuard at the wallet layer reasons=[RECIPIENT_DENIED]
>> Bridge attempt 3: amount over per-tx cap
✓ Bridge call BLOCKED reasons=[AMOUNT_EXCEEDS_PER_TX, AMOUNT_EXCEEDS_DAILY_CAP]Same wrap, every protocol — no per-protocol integration code in AgentGuard.
Policy schema
Policies are versioned JSON. All amount strings are decimal integers in the token base unit (e.g., "1000000" for 1 USDT, since USDT has 6 decimals).
{
"version": "1.0.0",
"agentId": "agent-001",
"rules": {
// Per-transaction maximum (decimal string, base units).
"maxAmountPerTx": "10000000",
// Rolling 24h cumulative cap, per (chain, token).
"dailyCap": "100000000",
// Rolling 1h transaction rate limit (count of approved tx).
"maxTxPerHour": 5,
// Allowed/denied addresses (case-insensitive). deniedRecipients always wins.
"allowedRecipients": ["0xabc..."],
"deniedRecipients": ["0xdead..."],
// Allowed token contracts. Empty/omitted = no constraint.
"allowedTokens": ["0xdAC17F958D2ee523a2206206994597C13D831ec7"],
// Allowed chain identifiers. Empty/omitted = all chains allowed.
"allowedChains": ["ethereum", "arbitrum"]
}
}The policy is hashed (canonical-JSON SHA-256) at registration time, and the hash is embedded in every receipt. Any change to the policy produces a new hash, so receipts are bound to the exact rule set that was active when the decision was made.
API reference
getAgentGuardMiddleware(config) (default export)
Creates a WDK middleware function that wraps every derived account.
| Field | Type | Description |
|---|---|---|
| policy | Policy | The policy to enforce. |
| chain | string (optional, recommended) | The blockchain identifier — pass the same value used in wdk.registerMiddleware(chain, fn). Falls back to reading account._config.chain if available, then "unknown". |
| signerSeed | Uint8Array (optional) | 32-byte ed25519 seed. Persist this if you need cross-session receipt verification. If omitted, a random seed is generated. |
| now | () => number (optional) | Clock function returning unix ms. Defaults to Date.now. Useful for tests. |
Returns: { middleware, signer, stores }
middleware— pass towdk.registerMiddleware(chain, ...).signer— the ed25519 signer (public key available viasigner.getPublicKeyHex()).stores—Map<address, ReceiptStore>populated as accounts are derived.
wrapAccount(account, options)
Lower-level helper. Wraps an existing IWalletAccount directly. Useful when you are not using @tetherto/wdk but still have a WDK-shaped account.
| Field | Type | Description |
|---|---|---|
| policy | Policy | The policy to enforce. |
| chain | string | The blockchain identifier. |
| receiptStore | ReceiptStore (optional) | Use an existing store. |
| signer | Signer (optional) | Use an existing signer. |
| now | () => number (optional) | Clock function. |
Returns: { account, receiptStore, signer } — the wrapped account is the same reference passed in (its sendTransaction / transfer are replaced with guarded versions).
loadPolicy(filePath) / parsePolicy(text)
Load a policy from disk (Node + Bare via bare-fs) or parse one in-memory. Throws PolicyError on malformed input.
hashPolicy(policy)
Returns a stable lowercase hex SHA-256 of the canonical-JSON serialisation of the policy.
validateIntent(policy, intent, history?)
Pure function. Evaluate an intent against a policy and a history of approved operations. Returns { allow: boolean, reasons: PolicyReason[] }. Used internally by the proxy; exported for advanced use cases (dry-run, simulation, custom integrations).
createReceiptStore(signer) / createEd25519Signer(seed?)
Compose your own decision pipeline. The proxy uses these under the hood.
Errors
PolicyError— thrown byloadPolicy/parsePolicy/hashPolicyfor malformed policies.PolicyDeniedError— thrown by guardedsendTransaction/transfer. Carriesreasons: PolicyReason[]andreceipt: Receipt.
Receipt format
{
"id": "1700000000000-1a2b3c4d",
"timestamp": 1700000000000,
"chain": "ethereum",
"action": "transfer",
"recipient": "0xabc...",
"amount": "1000000", // decimal string, base units
"token": "0xdAC17F...", // omitted for native transfers
"decision": "allow", // "allow" | "deny"
"reasons": [], // PolicyReason[] — non-empty on deny
"policyHash": "f1c4...e9", // SHA-256 of canonical policy JSON
"agentId": "agent-001",
"publicKey": "ab12...", // ed25519 public key, hex
"signature": "9f44...12" // ed25519 signature over canonical body, hex
}To verify a receipt: re-canonicalise the body (everything except signature), and verify the ed25519 signature against publicKey.
Development
npm install
npm run lint # standard
npm test # brittle (Node)
npm run test:bare # brittle-bare (requires bare runtime installed)
npm run build:types # tsc → ./types
npm run example # zero-setup stub demo
npm run example:integration # real WDK + Polygon Amoy testnet (needs MNEMONIC)
npm run example:composed # AgentGuard wrapping a WDK protocol-shaped objectConventions
This module follows the Tether WDK module conventions:
- ES modules, JSDoc-typed,
tsc-generated.d.ts. standardlint,brittletests.- Per-file Apache-2.0 copyright headers.
bare.jsentry viabare-node-runtime/global+ import attributes.AGENTS.mddescribing project structure for AI assistants.
License
Apache-2.0 © Hebx
