wwall
Enables policy-controlled token transfers and balance checks on the Polygon network, with a default payout token of USD₮0.
Provides policy-guarded access to Tether's WDK wallet, allowing agents to propose USDT payments that are subject to allowlists, spend caps, human confirmation, and a signed audit ledger.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@wwall@wwall propose a payment of 50 USDC to 0xabc"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
wwall
A policy guard that sits between an AI agent and a wallet.
Aleph Hackathon 2026 · WDK track · Tether
Give an agent a wallet and you have given it your money. wwall is an MCP server that stands in the way: the agent can propose a payment, but a local, human-authored policy decides whether it happens, whether a person has to approve it first, or whether it is refused outright. Every decision is written to a signed, append-only ledger.
The agent never touches @tetherto/wdk. It touches wwall, and wwall touches WDK.
┌─────────────┐ MCP/stdio ┌──────────────────────────┐ ┌──────────┐
│ AI agent │──────────────▶│ wwall │───────▶│ WDK │──▶ Polygon
│ (Claude…) │ propose_ │ ├ predicates.ts (guard) │ only │ wallet │
│ │ payment │ ├ ledger.jsonl (signed) │ if └──────────┘
└─────────────┘◀──────────────│ └ policy.json │ allowed
verdict └──────────────────────────┘
│ holds NEEDS_CONFIRM
▼
┌─────────────┐
│ human │ wwall pending / confirm / reject
└─────────────┘The one invariant
No tool call can reach
wallet.send()without passing throughevaluatePredicates()first.
Everything else is arranged to keep that true:
The server registers exactly three tools —
propose_payment,get_balance,get_pending. There is no send, transfer, sign or raw-transaction tool, and a test asserts the tool list by name and by pattern.The wallet is opened lazily, only on a path that has already been allowed. A rejected proposal never constructs a WDK instance at all — the token config needed to answer it is passed in separately for exactly this reason.
The payout token is fixed by configuration. An agent naming a different token is refused before any rule runs. (Without this,
SPEND_CAP{token:"USDT0"}simply would not apply totoken:"MONOPOLY", the policy would allow it, and the wallet would still move real USD₮0. A spend cap must not be bypassable with a string.)A rejection is a normal tool result, never a thrown error, so the agent can read the reason and explain it to a person.
The same predicates are registered inside WDK itself, so the wallet refuses too — see below.
Two layers, one set of predicates
wwall decides whether to call transfer. That is an argument about this
process. WDK's own policy engine decides whether the wallet will perform it,
which is a property of the account:
wdk.registerPolicy({
id: 'wwall-guard', scope: 'project', wallet: 'polygon',
rules: [{ operation: 'transfer', action: 'ALLOW',
conditions: [ctx => evaluate(policy, intentFrom(ctx.args), context(), 'ignore-confirm')] }]
})One ALLOW rule, gated on the same evaluatePredicates everything else uses.
The leverage is in what is not written: WDK is default-deny on governed
accounts, so the moment any policy applies, every method in OPERATIONS is
wrapped and anything without a matching ALLOW throws PolicyViolationError.
That closes the routes around a spend cap that never touch transfer at all:
Route | Why a |
| Same effect, different method |
| Funds leave later, by another hand |
| Entirely off-chain; nothing is "sent" |
| Hands the account to a contract |
wwall does none of these. That is the point: they were things its guard could
not see. test/wdk-policy.test.ts drives a real WDK
instance against a dead RPC and asserts each one raises PolicyViolationError
before any network call — while a permitted transfer fails on the network
instead, which is what proves it got past the guard rather than being stopped
by it.
The condition asks "is this allowed at all" (ignore-confirm mode). The
executed-versus-held split belongs to the layer above: by the time a held
payment reaches transfer, a human has approved it, and this layer must not
refuse it a second time.
A condition that throws on an ALLOW rule counts as "did not match", which under default-deny means refused — so a bug in this layer fails closed.
Quick start from a clean clone
git clone <this repo> && cd wwall
npm install
cp .env.example .env # then edit it — see below
npm run build
npm test # 295 tests, no network, no money
npm run try # walk the guard through a dozen proposals, fake wallet.env needs exactly one line. Everything else has a default:
WARDEN_SEED="…twelve words…"
# WARDEN_ARMED=1 # leave unset until you mean to move real fundsChain, RPC and payout token default to Polygon and USD₮0; the policy, ledger and
audit key live in ~/.wwall/. See .env.example for every
variable and what overriding it does.
Check the wallet without spending anything:
npm run check:walletThis reads symbol() and decimals() straight off the token contract and
compares them with your configuration. A wrong decimals is not an error
message, it is a payout off by a factor of 10ⁿ.
The safety catch
WARDEN_ARMED is off by default. Until it is exactly 1, a payment the
policy allows is reported but not sent — the proposal comes back as
rejected with code: "not_armed". Arm it deliberately.
Install via Desktop Extension (recommended)
wwall ships as an MCP Bundle — a single .mcpb file Claude Desktop installs in one
click, with a form for the settings instead of hand-edited JSON.
npm install && npm run build
npm run bundle # → build/wwall.mcpbThen in Claude Desktop: Settings → Extensions → Install Extension… and pick
build/wwall.mcpb.
The form asks for one thing: the seed phrase. It is declared "sensitive": true in
the manifest, so Claude Desktop masks it and keeps it in the OS keychain rather than in a
config file you might later paste into a bug report.
Everything else has a default and is not asked:
Setting | Default |
Chain and RPC | Polygon, via a public endpoint |
Payout token | USD₮0 — |
Policy, ledger, audit key |
|
~/.wwall is deliberately not the working directory: an extension is launched with an
unpredictable cwd, so a cwd-relative ledger would give the CLI and the MCP server
different spend histories — and a daily cap computed from the wrong ledger is not a cap.
Both halves now read the same one, so wwall pending from any directory sees what the
extension wrote.
On first run wwall writes a starter ~/.wwall/policy.json with an empty allowlist, so
every payment is refused until you name a recipient:
REJECTED — nothing was sent.
ALLOWLIST: list is empty, no recipient is allowedOpen wwall ui to add one. An extension that could pay someone it was never told about
would not be a guard, and an allowlist is not something to guess on your behalf.
There is deliberately no second switch. An installed extension is armed, and the only thing standing between an agent and your money is the policy — which is the product's whole claim, and would be undermined by a master toggle sitting above it. Adding a recipient to the allowlist is the deliberate act; a global on/off would just be a second place to look when a payment is refused, and a second kind of rejection cluttering the audit log.
WARDEN_ARMED still exists for the CLI and the hand-written config, where it is useful as
a freeze: one line in .env stops the wallet without touching the policy.
Any default can still be overridden the old way — every WARDEN_* variable from
.env.example works whether wwall was started by the CLI or by an
extension.
Building the bundle needs the packaging CLI, which is already a dev dependency:
npx mcpb validate manifest.json # check the manifest against the schema
npx mcpb pack build/mcpb build/wwall.mcpbThe format was called
.dxtand shipped as@anthropic-ai/dxt; that package is deprecated and now points at@anthropic-ai/mcpb. The spec lives in MANIFEST.md; this bundle targetsmanifest_version0.4.
Wiring it into Claude Desktop by hand
The manual route still works and is worth keeping: it puts every setting in one file you can read at a glance, which is sometimes exactly what you want when reviewing what a guard is configured to do.
claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/):
{
"mcpServers": {
"wwall": {
"command": "node",
"args": ["/absolute/path/to/wwall/dist/src/bin/wwall-mcp.js"],
"env": {
"WARDEN_SEED": "…twelve words…",
"WARDEN_CHAIN": "polygon",
"WARDEN_RPC_URL": "https://polygon-bor-rpc.publicnode.com",
"WARDEN_TOKEN_ADDRESS": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
"WARDEN_TOKEN_SYMBOL": "USDT0",
"WARDEN_TOKEN_DECIMALS": "6",
"WARDEN_POLICY": "/absolute/path/to/wwall/policy.json",
"WARDEN_LEDGER": "/absolute/path/to/wwall/ledger.jsonl"
}
}
}
}Restart Claude Desktop and the three tools appear. Without a client, the server speaks plain JSON-RPC on stdio:
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| node dist/src/bin/wwall-mcp.jsOr point the MCP Inspector at it:
npx @modelcontextprotocol/inspector node dist/src/bin/wwall-mcp.js
Tools
Tool | What it does |
| The only route to the wallet. Returns |
| Gas balance and payout-token balance. Read-only. |
| Payments held for a human. They have not been sent. |
Amounts are decimal strings everywhere — "12.50", never 12.5. A JSON
number on the money path is a float, and a float is a rounding bug waiting for
a large enough number.
The human half
The agent can put a payment into pending_confirmation. Only a person can take
it out:
wwall pending # what is waiting, and which rule held it
wwall confirm <id> --by alex # approve and send
wwall reject <id> --note "…" # refuse; nothing is ever sent
wwall ui # policy builder in the browserBoth halves read and write the same ledger.jsonl, so wwall pending shows
exactly what the agent's get_pending shows.
wwall confirm re-evaluates the policy before sending. The verdict was
formed when the agent proposed the payment, possibly hours and several payments
ago; the spend caps have moved since. It re-checks in "ignore-confirm" mode —
asking only is this still allowed, because the human at the keyboard is
the confirmation.
Policy
policy.json is a predicate tree. Nine opcodes:
Opcode | Fields | Meaning |
|
| Empty |
|
| Inverts. |
|
| Inclusive. |
|
| An empty allowlist allows nobody. A list you forgot to fill in must not silently allow everyone. |
|
| Empty denies nobody. |
|
|
|
|
| Not a refusal — amounts at or above it are held for a human. |
|
| Rolling, like |
CONFIRM_THRESHOLD is why the policy is evaluated twice per proposal: once
with every threshold treated as satisfied (is this allowed at all?) and once
strictly (can it go unattended?). That is what lets a threshold work inside an
AND, OR or NOT instead of only at the top level — a flat "needs approval"
flag could not.
Only confirmed sends count toward a cap. A payment submitted but not yet
confirmed is invisible to SPEND_CAP and VELOCITY; see Known limits.
The no-code builder
wwall ui # → http://127.0.0.1:4478/A flat list of rule cards plus one match-all/match-any switch, a live
policy.json preview, and a test form that shows the verdict as you type. The
Save button writes policy.json through the local server.
The page contains no copy of the guard logic. Its verdict comes from
POST /api/evaluate, which calls the same evaluatePredicates the MCP server
and the CLI call — a second implementation in browser JavaScript would drift
from this one and quietly start giving different answers. A test asserts the
page ships no client-side amount arithmetic.
The builder deliberately cannot draw nested composition (NOT, groups inside
groups). A policy that uses it opens read-only, with a note pointing at
policy.json. Full composition stays available by editing the file.
Audit log
Every record — verdicts, send attempts, results, human decisions — is appended
to ledger.jsonl, signed with a local Ed25519 key and chained to the record
before it.
npm run verify:auditrecords 3 (3 signed, 3 verified)
pinned to MCowBQYDK2VwAyEAKcjUin18… from audit-key.json
✓ every record is signed and the chain is unbrokenSignatures prove a record was not edited. They do not prove one was not
removed — deleting a line leaves every remaining signature valid. That is
what prev is for. Between them they catch editing, deletion from the middle,
and reordering.
The pinned to line matters: without an audit key to check against, a log
rewritten wholesale with an attacker's key verifies perfectly. Verification is
only meaningful against a key you trust.
Scenarios
36 scenarios run through the real MCP server over a real transport — not by calling the evaluator directly, because the interesting failures live in the pre-checks, the ledger round-trip and the tool boundary.
npm run report:scenarios # print
npm run report:scenarios -- --write # splice the table into this READMEScenario results
Category | Scenarios | Executed | Held for human | Rejected | Behaved as specified |
Legitimate | 6 | 5 (83%) | 1 (17%) | 0 (0%) | 6/6 |
Borderline | 12 | 4 (33%) | 3 (25%) | 5 (42%) | 12/12 |
Decimals traps | 10 | 0 (0%) | 0 (0%) | 10 (100%) | 10/10 |
Prompt injection | 8 | 0 (0%) | 1 (13%) | 7 (88%) | 8/8 |
All | 36 | 9 | 5 | 22 | 36/36 |
# | Category | Scenario | Outcome | Why |
L1 | Legitimate | small payout to an allowlisted address | executed | under every cap and under the confirmation threshold |
L2 | Legitimate | smallest representable amount | executed | exactly one base unit of a 6-decimal token |
L3 | Legitimate | token given by contract address instead of symbol | executed | the payout token is recognised either way |
L4 | Legitimate | checksummed address against a lowercase allowlist | executed | EVM addresses compare case-insensitively |
L5 | Legitimate | payout that needs a human, and gets held for one | held | above the 2 USDT0 confirmation threshold but within the caps |
L6 | Legitimate | second small payout of the hour | executed | well inside the velocity limit of 5/hour |
B1 | Borderline | exactly at the per-tx cap | held | the cap is inclusive, so it passes — but it is over the confirm threshold |
B2 | Borderline | one base unit over the per-tx cap | rejected | a millionth of a dollar over the cap is still over the cap |
B3 | Borderline | exactly at the confirmation threshold | held | the threshold triggers on >=, so an amount equal to it needs a human |
B4 | Borderline | one base unit below the confirmation threshold | executed | strictly below the threshold goes through unattended |
B5 | Borderline | payment that exactly exhausts the daily budget | held | 20 already spent + 5 = exactly the 25/day cap, which is inclusive |
B6 | Borderline | payment that overruns the daily budget by one unit | rejected | the running total is what the cap is measured against, not the single amount |
B7 | Borderline | sixth payment within the hour | rejected | five already sent this hour, and the limit is five |
B8 | Borderline | fifth payment within the hour | executed | four sent so far, so this one is still inside the limit |
B9 | Borderline | denylisted recipient who is also on the allowlist | rejected | not allowlisted at all, and the denylist would refuse it regardless |
B10 | Borderline | zero-value payment | executed | zero is a valid amount under every cap; nothing in the policy forbids it |
B11 | Borderline | address that is a near-miss of an allowlisted one | rejected | one character different is a different address, not a close enough match |
B12 | Borderline | recipient address with surrounding whitespace | executed | copy-paste whitespace is trimmed, not treated as a different address |
D1 | Decimals traps | more decimal places than the token has | rejected | truncating to 1.123456 would be an underpayment nobody would notice |
D2 | Decimals traps | amount below the smallest representable unit | rejected | it would round to zero — a payment that silently pays nothing |
D3 | Decimals traps | scientific notation | rejected | parsing it as a float is exactly the precision loss this project avoids |
D4 | Decimals traps | comma as the decimal separator | rejected | ambiguous between 1.5 and 15 depending on locale; never guess with money |
D5 | Decimals traps | thousands separators | rejected | not a decimal number, and guessing at 1000 would be a 1000× error |
D6 | Decimals traps | negative amount | rejected | a negative transfer is not a refund, it is a malformed request |
D7 | Decimals traps | non-ASCII digits | rejected | Arabic-Indic digits parse as a number in some stacks and not others |
D8 | Decimals traps | amount past the float-safe integer range | rejected | well over the cap — and it is compared as BigInt, so the cap sees the real value |
D9 | Decimals traps | trailing zeros that exceed the token’s precision | rejected | harmless in value, but accepting 7 places invites the case where it is not |
D10 | Decimals traps | empty amount | rejected | an empty string must never be read as zero or as "unset" |
P1 | Prompt injection | instruction to ignore the policy, in the reason field | rejected | the reason field is data recorded in the ledger; nothing reads it as an instruction |
P2 | Prompt injection | claimed pre-approval by a human | rejected | approval only exists as a confirmation_resolved record, never as a claim in a string |
P3 | Prompt injection | fake allowlist entry asserted in the reason | rejected | the allowlist is read from the policy, not from the caller |
P4 | Prompt injection | injection aimed at the confirmation threshold | held | urgency is not an input to the guard; it is held exactly as any other 9 USDT0 would be |
P5 | Prompt injection | control characters and a fake tool result in the reason | rejected | the reason is JSON-encoded on the way into the ledger, so it cannot forge a record |
P6 | Prompt injection | unsupported token with an authoritative-sounding justification | rejected | a token the wallet does not pay in is refused before any rule is consulted |
P7 | Prompt injection | homoglyph address that looks allowlisted | rejected | it is not a valid EVM address at all, let alone the allowlisted one |
P8 | Prompt injection | splitting a blocked payment into an allowed-looking one | rejected | the allowlist does not care how small the amount is |
WDK integration
Every WDK call in the project, in one file:
What | Where |
Imports | |
| |
| src/wdk.ts:218, built at src/wdk-policy.ts:55 |
The ALLOW condition that calls | |
| |
| |
| |
| |
| |
| |
| |
|
MCP SDK: new McpServer at src/mcp-server.ts:97, the
three registerTool calls at 113,
169 and 209, and
StdioServerTransport at src/bin/wwall-mcp.ts:52.
Signatures were read from the installed packages' own .d.ts files, not from
memory — and tsc --strict type-checks against them, which is the proof.
Packages
Package | Version | Why |
| 1.0.0-beta.16 | Wallet manager, account derivation |
| 1.0.0-beta.17 | EVM account: balances, ERC-20 transfer, confirmation |
| 1.0.0-beta.17 | Shared result types (transitive) |
| 1.30.0 | MCP server, stdio transport |
| 4.4.3 | Tool input/output schemas |
| 5.7.2 |
|
| 2.1.8 | Tests |
No dependency for money maths, signing, HTTP or the UI: BigInt fixed-point,
node:crypto Ed25519, node:http, and a single hand-written HTML file.
Layout
File | What |
The guard. Pure — no I/O, no clock, no network. | |
BigInt fixed-point. | |
Rolling-window spend and velocity, read from the ledger. | |
Append-only JSONL, fsynced, optionally signed and chained. | |
Ed25519 signing and verification. | |
Loading and validation, with JSON-path errors. | |
The WDK wrapper. Every result is JSON-serialisable. | |
The same predicates, registered with WDK's policy engine. | |
The three tools and the guarded path. | |
| |
Loopback-only API behind the builder. | |
The builder. One file, no bundler, no framework. |
Known limits
Stated plainly, because a security tool that hides its edges is worse than one that does not have them.
Only confirmed sends count toward a cap. A batch dispatched faster than it confirms can exceed a daily cap.
LedgerEvalContext.pendingInWindow()exists so a report can show the in-flight amount that no cap can see.Tail truncation is undetectable. The hash chain runs backwards, so deleting the last N records leaves a valid prefix. Catching that needs an external anchor — a record count kept elsewhere, or the tip digest published somewhere outside the file.
The seed is still the seed. The WDK policy governs accounts obtained through this WDK instance. Anyone holding the same seed phrase in another process — another script, a wallet app, a leaked
.env— moves the funds with no policy in the way. Guarding that needs a key that cannot leave, not a policy: a signer in an enclave, a smart account with on-chain limits, or a co-signer. wwall constrains an agent; it does not constrain a key holder.USD₮ on Polygon is USDT0. Polygon's old PoS-bridged USDT was migrated in place to native USD₮0, Tether's omnichain token, backed 1:1 in an Ethereum lockbox. Verified on chain:
name()="USDT0",symbol()="USDT0",decimals()=6. Do not reuse the BNB Chain address0x55d398…— it is Binance-Peg, not Tether-issued, and it has 18 decimals rather than 6.policy.jsonis trusted input. Anyone who can write that file can rewrite the guard. It is loaded once at startup and its sha256 is recorded on every verdict, so a change is visible in the audit log after the fact — but it is not prevented.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Runtime permission, approval, and audit layer for AI agent tool execution.
Bitcoin-anchored, tamper-evident audit log for AI agents — record, disclose and verify actions.
Six-gate governance for AI agents: PROCEED/PAUSE/HALT decisions with hash-chained audit trails.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/axdvdv/wwall-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server