Blog

MetaMask Automated Testing For Wallet Flows

MetaMask automated testing should inspect account access, chain changes, signature prompts, rejection recovery, and transaction state with browser evidence.

Drew Stone
browser-agentmetamaskwallet-testing
An editorial still life about an agent operating a web browser

A swap page says that it will approve 10 USDC. MetaMask opens with a different network or an unlimited allowance for an unfamiliar contract. A page-only test still sees the “Approve” button, clicks it, and reports success. The test passed the web page while missing the security decision the user saw.

MetaMask automated testing must inspect both sides of that boundary. MetaMask is a browser and mobile wallet documented in the official developer documentation. A dapp is a web application that asks the wallet to expose an account, switch networks, sign data, or submit a transaction. The dapp sends a request through the page’s wallet provider; MetaMask presents the approval; and, if a broadcast transaction is accepted and included, the chain records it. For an allowance, the prompt must identify the token-spending limit and the spender contract receiving it.

Tangle Browser Agent drives a real Chromium session from a goal; bad is the short name of its command-line program. Its public wallet-testing guide runs MetaMask or Rabby against a local Anvil chain using two routes. The extension’s background RPC calls go to Anvil, while page-level RPC interception forwards only calls involving the configured test-wallet address to Anvil; protocol and pool data continue to use real RPC endpoints. The driver records page actions and screenshots. A useful wallet test should add the wallet prompt, provider response, and final application state so a reviewer can compare what the dapp requested, what the wallet displayed, and what happened afterward. For the wider state model, read DeFi Wallet Testing With Browser Agents. For the evidence record, read AI Browser Testing With Evidence Traces.

Test the wallet boundary a user sees

The word “connect” hides several separate promises. The application must request the right account access. The wallet must show the request. The page must reflect the selected account. The account can change later. The chain can change later.

User-visible actionApplication conditionWallet evidence
connectthe page shows the selected test account or a clear connected staterequested account access and selected account
reject connectionthe page stays usable and explains the rejectionwallet rejection and provider error
switch networkthe page and wallet agree on the chainrequested and selected chain
sign a messagethe page describes the message purposeexact message or typed-data prompt
approve an allowancethe page names the token and spenderallowance amount, token, and spender
submit a transactionpending, success, and failure states are distincttransaction details and outcome
change accountaccount-specific data refreshes or the session closesaccountsChanged event and new page state

The test should save the page screenshot immediately before the prompt and the wallet screenshot at the decision. The first shows the context the user had. The second shows the context the wallet rendered. The final page shows whether the application reported the result honestly.

Use the public provider interface

An EIP, or Ethereum Improvement Proposal, is a public specification for an Ethereum interface or protocol change. EIP-1193 defines a common JavaScript provider API, or application programming interface, for Ethereum applications and wallets. The provider exposes a request method for wallet and chain calls. RPC means remote procedure call, the request-and-response interface used to ask the wallet or chain client for state and actions. EIP-1193 also defines events for connection, disconnection, chain changes, and account changes.

Those events are part of the test surface. An app that never handles accountsChanged can keep showing balances for the previous account. An app that ignores chainChanged can display a quote for one chain while the wallet signs on another. An app that treats a rejected request as success can leave a false “Connected” or “Submitted” state behind.

EIP-1193 recommends provider error code 4001 for user rejection. It recommends 4100 for unauthorized requests, 4200 for unsupported methods, 4900 for a disconnected provider, and 4901 when the requested chain is unavailable while another chain remains connected. Record the code when the app’s recovery behavior matters.

An illustrative browser-side observer looks like this:

type ProviderError = Error & { code?: number; data?: unknown }

const provider = window.ethereum

if (!provider) {
  throw new Error('MetaMask or another injected provider is unavailable')
}

provider.on('accountsChanged', (accounts: string[]) => {
  console.log({ event: 'accountsChanged', accounts })
})

provider.on('chainChanged', (chainId: string) => {
  console.log({ event: 'chainChanged', chainId })
})

try {
  const accounts = await provider.request({ method: 'eth_accounts' })
  const chainId = await provider.request({ method: 'eth_chainId' })
  console.log({ accounts, chainId })
} catch (error) {
  const providerError = error as ProviderError
  console.error({
    message: providerError.message,
    code: providerError.code,
    data: providerError.data,
  })
}

This is an illustrative EIP-1193 example. MetaMask Connect is MetaMask’s current connection software development kit, or SDK, for connecting supported wallet environments to applications. An application that uses MetaMask Connect may use that SDK rather than reading an injected provider directly. The MetaMask Connect documentation says the product can select a desktop extension, QR-code, or mobile-app connection method based on the user’s environment. The test should assert the connection behavior that the application supports instead of assuming the extension is always present.

Make signing a data test

A signing test should inspect the exact message, typed-data domain, spender, value, destination, and chain shown in the prompt. Personal signing presents a message. Typed signing presents structured fields. Transaction signing presents a destination, value, data, and gas-related context according to the wallet and network.

EIP-712 defines a standard for hashing and signing typed structured data. It includes typed fields and a domain separator so a wallet can present more meaningful context than an opaque byte string. The standard does not provide replay protection. In this context, a nonce is a number used once to prevent a signed operation from being reused or applied out of order. An application still needs a nonce, expiry, chain binding, or another policy appropriate to its operation.

An illustrative typed-data fixture can make the intended prompt reviewable:

{
  "domain": {
    "name": "Example Exchange",
    "version": "1",
    "chainId": 11155111,
    "verifyingContract": "0x0000000000000000000000000000000000000001"
  },
  "types": {
    "EIP712Domain": [
      { "name": "name", "type": "string" },
      { "name": "version", "type": "string" },
      { "name": "chainId", "type": "uint256" },
      { "name": "verifyingContract", "type": "address" }
    ],
    "Permit": [
      { "name": "owner", "type": "address" },
      { "name": "spender", "type": "address" },
      { "name": "value", "type": "uint256" },
      { "name": "nonce", "type": "uint256" },
      { "name": "deadline", "type": "uint256" }
    ]
  },
  "primaryType": "Permit",
  "message": {
    "owner": "0x0000000000000000000000000000000000000002",
    "spender": "0x0000000000000000000000000000000000000003",
    "value": "1000000",
    "nonce": "0",
    "deadline": "2000000000"
  }
}

The addresses and numbers above are illustrative placeholders. The test should compare the prompt to the expected fixture before a controlled test wallet approves it. It should run the rejection path as well. The application must not record a session, allowance, or completed action when the user rejects the request.

The provider request and the wallet screenshot prove different things. The request shows the structured data the dapp handed to MetaMask; the screenshot shows what MetaMask rendered for the user. Save the intercepted eth_signTypedData_v4 request as captured-wallet-request.json and the fixture above as expected-permit.json, then make the field comparison explicit:

import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'

type TypedDataRequest = {
  method: 'eth_signTypedData_v4'
  params: [string, string]
}

const captured = JSON.parse(
  await readFile('captured-wallet-request.json', 'utf8'),
) as TypedDataRequest
const expected = JSON.parse(
  await readFile('expected-permit.json', 'utf8'),
)
const actual = JSON.parse(captured.params[1])

assert.equal(captured.method, 'eth_signTypedData_v4')

function assertTypedDataMatches(
  actualData: typeof expected,
  expectedData: typeof expected,
) {
  assert.deepEqual(actualData.domain, expectedData.domain)
  assert.deepEqual(actualData.types, expectedData.types)
  assert.equal(actualData.primaryType, expectedData.primaryType)
  assert.deepEqual(actualData.message, expectedData.message)
}

assertTypedDataMatches(actual, expected)

const wrongSchema = structuredClone(expected)
wrongSchema.domain.name = 'Lookalike Exchange'
wrongSchema.types.Permit[1].type = 'bytes32'
assert.throws(() => assertTypedDataMatches(actual, wrongSchema))

const wrongSpender = structuredClone(expected)
wrongSpender.message.spender =
  '0x0000000000000000000000000000000000000004'
assert.throws(() => assertTypedDataMatches(actual, wrongSpender))

The two negative fixtures change the expected domain and signing schema, then the spender. They prove that the comparison rejects both a lookalike prompt shape and a prompt for the wrong contract. This Node assertion is independent of the browser driver. Adapt the capture step to the application’s test setup, and keep the prompt screenshot beside the JSON because a correct request can still be presented poorly.

Build a repeatable MetaMask fixture

The fixture is the state prepared before the browser opens. Record wallet and browser versions, permissions, balances, nonce state, and application data so a rerun can isolate fixture drift.

FixtureRequired control
extensionknown MetaMask version installed and enabled before the run
accountdisposable address with test funds and no production authority
networkexpected chain configured before the dapp opens
permissionsclean connection permissions between cases where needed
balancesreset token and native-currency balances
noncedeterministic or isolated chain state
app datapredictable account, quote, and session records
browserrecorded browser version, viewport, locale, and extension set

Local chains are useful for repeatable transaction results. A local Ethereum development chain can provide repeatable transaction results, but the exact chain tooling belongs to the application’s test setup. Live-network checks can reveal integration issues that a local chain hides. Keep those checks few, explicit, and funded only with disposable value.

The test should record the extension version with the browser version. A wallet UI can change independently of the dapp. When prompt identification fails, the team needs to know whether the page changed, the extension changed, or the fixture did not reach the expected state.

Stop at the signing prompt

The public Browser Agent manifest names the scoped package, bad binary, and safe discovery commands. The public driver README documents the wallet and DeFi commands; the linked wallet guide covers the full setup.

npm install -g @tangle-network/browser-agent-driver
npx playwright install chromium
bad --help
bad run \
  --url https://app.example.com \
  --goal "Connect the disposable MetaMask account, verify the configured test chain, open the swap preview, capture the wallet prompt, and stop before signing" \
  --wallet \
  --extension ./extensions/metamask \
  --user-data-dir ./.agent-wallet-profile \
  --wallet-preflight \
  --wallet-chain-id 31337 \
  --wallet-chain-rpc-url http://127.0.0.1:8545 \
  --no-headless

The example deliberately stops before an irreversible action. A controlled transaction case needs a separate goal, a disposable wallet, a local or test chain, and an explicit approval policy. In that separate case, Browser Agent’s wallet popup handler performs the approval only when --wallet-auto-approve is enabled; the test must then wait for the transaction receipt or a defined failure state before it can pass. Do not place seed phrases or private keys in the goal text.

The DOM, or Document Object Model, is the page’s structured tree of elements and attributes. The driver’s README documents the run’s observation modes, including DOM, vision, and hybrid approaches. Vision means taking rendered screenshots as model input. Hybrid combines the two. Wallet prompts and visual chain details often need screenshots even when page controls are easy to find in the DOM.

Give rejection its own test case

A suite that covers only successful approvals misses an important product path: the user’s rejection.

CaseExpected resultEvidence
reject account accesspage remains disconnected and offers retryprompt rejection, provider error, page state
reject chain switchpage explains the required chain and does not submitrequested chain, selected chain, error state
reject personal messageno session or authorization is recordedmessage prompt, 4001 error, app state
reject typed datano allowance or permission is claimedtyped-data prompt, rejection, app state
reject transactionpending state is cleared or marked rejectedtransaction prompt, error, retry state
close promptpage does not assume approvalprompt closed, timeout, stop reason

A false pass often comes from stale state. The application displays a connected account from its own memory after the wallet has disconnected. The UI shows “Submitted” after a request was sent but before a receipt or chain confirmation exists. The test should preserve the boundary between requested, signed, broadcast, pending, confirmed, and failed.

Split fast wallet smoke checks from deep runs

Wallet suites are expensive to debug because they depend on browser, extension, account, network, and data state. Use a small smoke case for every change that touches connection code. Reserve the deeper matrix for releases or changes that affect signing, allowances, transaction construction, or chain handling.

SuiteExample casesRequired reset
connection smokeconnect, reject, account changeclean permissions and disposable account
network smokewrong chain, switch, chain changeknown chain configuration
signing suitepersonal message, typed data, rejectfresh nonce and expected domain
transaction suiteapproval, submit, pending, revertisolated balances and local or test chain
release replayfull dapp journey with prompt screenshotsversioned browser, extension, and fixture

Keep the smoke case short enough to diagnose from one trace. Run the deeper suite when a release changes the parts of the flow that the smoke case cannot reach. Run the connection smoke case on each relevant change, and reserve the deeper matrix for changes to signing, allowances, or transaction submission.

Diagnose the first wrong boundary

A trace is the ordered record of the browser, wallet, provider, and chain observations for one run. Compare account, chain, prompt fields, provider errors, transaction hash, and receipt in timestamp order.

First divergenceLikely ownerEvidence to inspect
app account differs from wallet accountdapp state or connection layeraccountsChanged event and both screenshots
requested chain differs from selected chainnetwork configurationchainChanged event and wallet network
prompt spender differs from expected contracttransaction builder or routetyped data or transaction request
rejection leaves success stateerror handlingprovider code and app transition
pending never resolvesRPC, indexer, or receipt handlingrequest error, transaction hash (identifier), and timing
prompt cannot be reachedextension fixture or browser versionextension state, permissions, and screenshot

The evaluation compares the expected account, chain, action, and outcome with that trace. Keep a failed run failed when the evidence contradicts the goal. Keep it inconclusive when the prompt or final chain result was never observable.

Keep the fixture separate from the verdict

Browser Agent drives the page and records the wallet boundary. MetaMask remains the wallet that presents account, chain, message, allowance, and transaction details. The driver can make those states easier to inspect, but it cannot decide whether a contract or an economic outcome is safe.

If a team packages the check as a service, return the page evidence, wallet evidence, and chain result separately. Report browser state, wallet evidence, transaction hash, and receipt as separate verdict fields.

The limit of MetaMask automated testing

MetaMask automated testing can provide evidence that a dapp and a particular extension fixture presented a defined wallet flow. It does not prove the smart contract is safe. It does not prove the quoted exchange rate will remain available. It does not prove a transaction’s economic outcome is correct. It does not cover every MetaMask platform, mobile path, browser version, network, account, or wallet setting. It does not make a production wallet appropriate for unattended automation.

Use simulation to inspect calldata, a receipt to establish settlement, contract tests for invariants, and manual review for high-value signing. Treat a wallet prompt screenshot as evidence of what the user was asked to approve. Treat a chain receipt or application-owned confirmation as evidence of what happened afterward.

Make the prompt reviewable

Automate MetaMask flows when connection, signing, or transaction approval can change onboarding, revenue, or user trust. Keep the account, chain, prompt contents, rejection behavior, and final state visible in the trace. Do not accept a pass that proves only that a button was clicked. For screenshots, action events, stop reasons, and bounded recovery around those wallet states, read browser automation and the AI evidence loop.