@oceanalt/metamask-agent-wallet
v0.1.0
Published
MetaMask Agent Wallet (mm) plugin: check x402 payment signatures, token transfers and approvals with OceanAlt before MetaMask signs them. Allow, ask a human, or block. No keys, no custody.
Maintainers
Readme
@oceanalt/metamask-agent-wallet
A MetaMask Agent Wallet (mm) plugin. Before MetaMask signs an x402 payment authorization, a token transfer or an approval, it checks the request with OceanAlt's public API and returns one of three results:
| Result | What happens |
|---|---|
| allow | The request goes to MetaMask, which still applies its own policy, threat scanning and 2FA. |
| confirm | A human must agree first. Interactive: a yes/no prompt. Headless: status: "needs_confirmation" plus a code bound to this exact request; rerun with --confirm <code>. |
| block | Nothing is signed. There is no override. |
The plugin holds no keys and moves no funds. Signing is done by MetaMask through ctx.walletExecutor. If a check can't run (network error, HTTP error, unknown answer), the result is confirm, never allow.
Why signatures are checked too
An x402 payment is an EIP-3009 TransferWithAuthorization signature, not a transaction. MetaMask documents its Guard Mode outflow limit in terms of outgoing transfers. Public tests (bfullam/x402-guard, gap-test/) show a Guard Mode wallet signing such authorizations to sanctioned and mixer addresses without a prompt. This plugin puts typed-data signatures through the same checks as transactions. It can also count them toward a local 24h USDC cap.
Important limit: this is not an interception hook
The Agent Wallet plugin API (@metamask/agent-wallet/plugin, 7.1.0) lets a plugin add commands. It has no hook that runs before mm wallet sign-typed-data or mm wallet send-transaction. So this plugin only protects requests that go through its own commands. An agent can still call mm wallet … directly and skip it. skills/oceanalt-guard/SKILL.md tells agents to use these commands. That is guidance, not enforcement. Enforcement that can't be bypassed would have to live in MetaMask's own signing pipeline.
Commands
mm oceanalt check --chain-id <id> (--payload '<typed data>' | --to <addr> [--data 0x…] [--value <wei>]) [context]
mm oceanalt sign-typed-data --chain-id <id> --payload '<typed data>' [context] [--confirm <code>]
mm oceanalt send-transaction --chain-id <id> --to <addr> [--data 0x…] [--value <wei>] [context] [--confirm <code>]
mm oceanalt config [--api-key <key>|none] [--max-validity <s>] [--daily-cap <usdc>|none] [--require-signed-requirements true|false]
context: [--url <x402 resource url>] [--payment-required '<PAYMENT-REQUIRED header or JSON>'] [--intent-to <payee>] [--intent-amount <atomic units>]check signs nothing and needs no capabilities. sign-typed-data and send-transaction need wallet-submit, which you approve at install time. sign-typed-data takes the same --chain-id / --payload as mm wallet sign-typed-data.
What is checked
| Check | Source | Result |
|---|---|---|
| Decode the request | local | EIP-3009 Transfer/ReceiveWithAuthorization, EIP-2612 Permit, Permit2 (PermitSingle/Batch, PermitTransferFrom/Witness), native transfer, ERC-20 transfer, transferFrom, approve, increaseAllowance, permit, setApprovalForAll. Anything else, including any other contract call: confirm. |
| Counterparty (payee, spender, or called contract) | POST /api/decide {to, amountUsdc?, network} | decline → block, allow → allow, review / anything else / error → confirm. amountUsdc is sent only when the token is Circle USDC on a known chain. |
| Calldata vs declared payment (transactions) | POST /api/intent/check | mismatch (for example approve where a payment was declared) → block, unknown / error → confirm. Runs when --intent-to is given or the calldata is a transfer. |
| Typed data vs declared payment | local, same rules | A permit or Permit2 signature where a payment was declared, or in an x402 context (--url / --payment-required) → block. EIP-3009 payee or amount different from --intent-* → block. |
| Unlimited approval with no declared intent | local | confirm |
| Signature validity window | local | validBefore / deadline more than maxValiditySeconds (default 3600) ahead → confirm |
| x402 signed payment requirements | POST /api/x402/verify-requirements, or offline with pinned keys in config | tampered, bad signature, payTo/amount not signed → block. The signed payTo isn't among the 402's accepts → block. No seller signature → allow (confirm with --require-signed-requirements true). Key unavailable / error → confirm. |
| Payment endpoint | GET /api/endpoint?url= | risky → block, clear → allow, caution / unknown / error → confirm |
| Chain | local | A chain OceanAlt doesn't screen natively, or a typed-data domain chainId different from the signing chain → confirm |
| 24h USDC cap (off by default) | local file ~/.oceanalt/metamask-agent-wallet-ledger.json | Counts USDC signed or sent through this plugin, signatures included. Over the cap → confirm. Non-USDC payments with a cap set → confirm. |
What is not checked
- Requests signed outside this plugin's commands (see the limit above).
personal_signmessages (result is alwaysconfirm).- Whether a token contract is genuine. Only Circle USDC addresses are recognised, and only for amounts.
- The effects of arbitrary contract calls, multicalls, Permit2 witness contents and non-EVM chains.
- Whether the seller delivers what was paid for.
- An OceanAlt
allowis a screening result over public data. It isn't a guarantee.
Configuration
No API key is needed for the free endpoints. If you set one (mm oceanalt config --api-key … or OCEANALT_API_KEY), it is sent as x-api-key, and only to the OceanAlt host. Config lives in ~/.oceanalt/metamask-agent-wallet.json, written with file mode 0600 (the key is stored in plaintext; prefer the environment variable on shared machines). OCEANALT_BASE_URL and OCEANALT_MM_DIR override the host and the directory.
Every check sends to OceanAlt the counterparty address, chain, USDC amount and, for transactions, the target and calldata. When given, it also sends the payment URL and 402 requirements. Nothing else is sent, and no telemetry.
Install (local development)
npm install && npm run build # tsc + oclif manifest
mm config set experimentalPlugins true
mm config set experimentalAllowUnverifiedInstalls true
mm plugins install "file:$PWD" --accept-permissionsPlugins are a beta feature of mm and are off by default.
Library use
The checks are in lib/guard.mjs and have no MetaMask dependency:
import { createClient, screen } from "@oceanalt/metamask-agent-wallet";
const r = await screen({ kind: "typed-data", chainId: 8453, typedData }, { url, paymentRequired }, { client: createClient() });
// r.action: "allow" | "confirm" | "block"; r.findings: [{ check, outcome, code, detail }]Tests
npm test # node:test, fetch mocked; no networkLicense
MIT
