Skip to main content
Glama
axdvdv

wwall

by axdvdv

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 through evaluatePredicates() 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 to token:"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 transfer-shaped guard misses it

sendTransaction({to: token, data: <ERC-20 transfer calldata>})

Same effect, different method

approve(spender, MAX) then someone else's transferFrom

Funds leave later, by another hand

signTypedData of an EIP-2612 Permit

Entirely off-chain; nothing is "sent"

delegate(...) under ERC-7702

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 funds

Chain, 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:wallet

This 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.

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.mcpb

Then 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 — 0xc2132D05…, 6 decimals, verified on chain

Policy, ledger, audit key

~/.wwall/

~/.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 allowed

Open 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.mcpb

The format was called .dxt and shipped as @anthropic-ai/dxt; that package is deprecated and now points at @anthropic-ai/mcpb. The spec lives in MANIFEST.md; this bundle targets manifest_version 0.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.js

Or point the MCP Inspector at it: npx @modelcontextprotocol/inspector node dist/src/bin/wwall-mcp.js

Tools

Tool

What it does

propose_payment(to, amount, token, reason?)

The only route to the wallet. Returns executed + txHash, rejected + reason, or pending_confirmation + confirmationId.

get_balance(token?)

Gas balance and payout-token balance. Read-only.

get_pending()

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 browser

Both 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

AND / OR

rules[]

Empty AND allows (vacuous truth); empty OR denies.

NOT

rule

Inverts.

SPEND_CAP

token, amount, window: tx|day

Inclusive. day is a rolling 24 hours, not a calendar day — a calendar window can be bypassed by spending the cap at 23:59 and again at 00:01.

ALLOWLIST

addresses[]

An empty allowlist allows nobody. A list you forgot to fill in must not silently allow everyone.

DENYLIST

addresses[]

Empty denies nobody.

TIME_WINDOW

from, to

"HH:MM" on both ends = daily UTC window, inclusive, wrapping past midnight when from > to. Two timestamps = an absolute range.

CONFIRM_THRESHOLD

amount

Not a refusal — amounts at or above it are held for a human.

VELOCITY

maxTxCount, window: hour|day

Rolling, like SPEND_CAP.

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:audit
records   3  (3 signed, 3 verified)
pinned to MCowBQYDK2VwAyEAKcjUin18… from audit-key.json

✓ every record is signed and the chain is unbroken

Signatures 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 README

Scenario 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:14-15

new WDK(seed).registerWallet(chain, WalletManagerEvm, {provider, transferMaxFee})

src/wdk.ts:216

wdk.registerPolicy(…) — wwall's predicates as a WDK policy

src/wdk.ts:218, built at src/wdk-policy.ts:55

The ALLOW condition that calls evaluate(…, 'ignore-confirm')

src/wdk-policy.ts:85

account.simulate.transfer(…) — ask the engine without doing it

src/wdk.ts:250

wdk.getAccount(chain, index)

src/wdk.ts:228

account.getBalance() → native wei

src/wdk.ts:260

account.getTokenBalance(address) → base units

src/wdk.ts:261

account.quoteTransfer({token, recipient, amount})

src/wdk.ts:281

account.transfer({token, recipient, amount})the one place money moves

src/wdk.ts:304

account.waitForTransaction(hash, {target: 'confirmed'})

src/wdk.ts:328

wdk.dispose()

src/wdk.ts:364

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

@tetherto/wdk

1.0.0-beta.16

Wallet manager, account derivation

@tetherto/wdk-wallet-evm

1.0.0-beta.17

EVM account: balances, ERC-20 transfer, confirmation

@tetherto/wdk-wallet

1.0.0-beta.17

Shared result types (transitive)

@modelcontextprotocol/sdk

1.30.0

MCP server, stdio transport

zod

4.4.3

Tool input/output schemas

typescript

5.7.2

strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes

vitest

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

src/predicates.ts

The guard. Pure — no I/O, no clock, no network.

src/amount.ts

BigInt fixed-point. number appears nowhere on the money path.

src/context.ts

Rolling-window spend and velocity, read from the ledger.

src/ledger.ts

Append-only JSONL, fsynced, optionally signed and chained.

src/audit.ts

Ed25519 signing and verification.

src/policy.ts

Loading and validation, with JSON-path errors.

src/wdk.ts

The WDK wrapper. Every result is JSON-serialisable.

src/wdk-policy.ts

The same predicates, registered with WDK's policy engine.

src/mcp-server.ts

The three tools and the guarded path.

src/cli.ts

pending / confirm / reject / ui.

src/ui-server.ts

Loopback-only API behind the builder.

ui/index.html

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 address 0x55d398… — it is Binance-Peg, not Tether-issued, and it has 18 decimals rather than 6.

  • policy.json is 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.

-
license - not tested
Not graded
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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.

View all MCP Connectors

Latest Blog Posts

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