How It Works

stellar-wallet-mock is a Playwright testing library that mocks the Freighter wallet browser extension. It lets you run headless E2E tests against Stellar/Soroban dApps without installing a real extension or interacting with any wallet UI. This page explains the architecture in detail so you understand exactly what happens when you call installMockStellarWallet().

Origin & Inspiration

The core architecture of stellar-wallet-mock is adapted from @johanneskares/wallet-mock, an Ethereum wallet mock for Playwright by Johannes Kares. Both libraries share the same two-step pattern:

  1. page.exposeFunction() — registers a Node.js signing function so it's callable from the browser. The private key never enters the browser; all cryptography stays in Node.js.
  2. page.addInitScript() — injects a self-contained script (no closures, since it's serialized to a string) that pretends to be the real wallet extension.

The difference is what gets mocked. wallet-mock targets the Ethereum ecosystem, while stellar-wallet-mock targets Stellar:

wallet-mock (Ethereum)stellar-wallet-mock
ProtocolEIP-1193 / EIP-6963 providerFreighter window.postMessage
Signingviem WalletClient in Node.js@stellar/stellar-sdk Keypair in Node.js
DiscoveryDispatches eip6963:announceProvider eventSets window.freighter = true + pre-seeds localStorage
Exposed functioneip1193Request (single RPC handler)Three dedicated functions (transaction, auth entry, message)

The key adaptations for Stellar:

  • Protocol — Ethereum wallets use the EIP-1193 provider interface (a JavaScript object with a request() method). Freighter uses window.postMessage with specific source strings and message types. The mock replaces the EIP-6963 event dispatch with a postMessage listener.
  • Signing — wallet-mock uses viem for Ethereum signing (secp256k1). stellar-wallet-mock uses @stellar/stellar-sdk for Stellar signing (ed25519), including the Stellar-specific transaction hashing (network passphrase + envelope hash).
  • Discovery — Ethereum wallets announce themselves via eip6963:announceProvider events. Freighter signals its presence by setting window.freighter = true. The mock also pre-seeds localStorage for Stellar-specific libraries, which Ethereum wallets don't need.
  • Function granularity — wallet-mock exposes a single eip1193Request function that handles all RPC methods. stellar-wallet-mock exposes three separate functions because each Stellar signing type (transaction, auth entry, message) has different input processing.

Architecture

When your dApp communicates with the Freighter wallet, it doesn't talk to the browser extension directly. Instead, it uses window.postMessage() — a standard browser API for cross-context messaging. The real Freighter extension listens for these messages and responds. stellar-wallet-mock replaces that listener with its own, so your dApp can't tell the difference.

Playwright Test
    |
    v
installMockStellarWallet(page, secretKey)
    |
    |-- createWallet(secretKey)          -- creates a real Keypair in Node.js
    |
    |-- page.exposeFunction() x3         -- bridges Node.js signing into the browser
    |     * __stellarMockSignTransaction
    |     * __stellarMockSignAuthEntry
    |     * __stellarMockSignMessage
    |
    +-- page.addInitScript()             -- injects mock before the dApp loads
          * sets window.freighter = true
          * pre-seeds localStorage
          * listens for postMessage events (Freighter protocol)
          * routes signing requests to the exposed Node.js functions

The mock operates at the window.postMessage layer — the universal protocol that all Freighter integrations use. This means it works transparently with:

  • @stellar/freighter-api directly
  • @creit-tech/stellar-wallets-kit with FreighterModule
  • Scaffold Stellar apps
  • Any code that sends raw postMessage calls using the Freighter protocol

The Bridge Pattern

The central design challenge is that signing requires @stellar/stellar-sdk, which is a Node.js library. But the mock script runs inside the browser — injected via page.addInitScript() — where Node.js modules aren't available.

The solution is Playwright's page.exposeFunction(). This API registers a Node.js function and makes it callable from the browser as if it were a normal window function. Playwright handles the IPC (inter-process communication) behind the scenes.

Node.js side — register the signing function:

installMockWallet.ts (Node.js context)typescript
1await page.exposeFunction(
2 "__stellarMockSignTransaction",
3 async (transactionXdr: string): Promise<string> => {
4 // This runs in Node.js — stellar-sdk is available here
5 const kp = Keypair.fromSecret(secretKey);
6 const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase);
7 tx.sign(kp);
8 return tx.toXDR();
9 }
10);

Browser side — call it like a local async function:

browserMockScript (browser context)typescript
1// Inside the injected browser script
2const signedXdr = await window.__stellarMockSignTransaction(
3 data.transactionXdr
4);

When the browser calls window.__stellarMockSignTransaction(), Playwright:

  1. Sends the arguments from the browser process to the Node.js process
  2. Executes the Node.js function (where stellar-sdk signs the transaction)
  3. Sends the return value back to the browser
  4. Resolves the Promise in the browser

The private key never enters the browser. All cryptography stays in Node.js, just as it would with a real extension.

Browser Injection

The mock script is injected via page.addInitScript(browserMockScript, config). This Playwright API runs the given function in the browser context before any page JavaScript executes. This is critical — the mock must be in place before the dApp tries to detect or communicate with Freighter.

Because addInitScript serializes the function to a string, the script must be completely self-contained. It cannot close over any external variables or import any modules. All configuration is passed in as a parameter:

installMockWallet.tstypescript
1// config is serialized to JSON and passed into the browser
2await page.addInitScript(browserMockScript, {
3 publicKey: wallet.publicKey,
4 secretKey: secretKey,
5 network: wallet.network,
6 networkPassphrase: wallet.networkPassphrase,
7});

Once injected, the script does three things:

  1. Sets window.freighter = true — this is how freighter-api's isConnected() detects that the extension is present. Without it, the dApp short-circuits and never sends any messages.
  2. Pre-seeds localStorage — libraries like @creit-tech/stellar-wallets-kit and Scaffold Stellar check localStorage to see if a wallet was previously connected. The mock populates these keys so the dApp boots into a “connected” state without showing modal dialogs (which would hang headless tests).
  3. Listens for window.postMessage events — intercepts every message with source: "FREIGHTER_EXTERNAL_MSG_REQUEST" and responds with the appropriate data.

Message Protocol

The Freighter extension communicates with dApps via a specific postMessage protocol. Each request has a source of "FREIGHTER_EXTERNAL_MSG_REQUEST", a unique messageId, and a type field indicating what the dApp wants. The mock intercepts these and responds with source: "FREIGHTER_EXTERNAL_MSG_RESPONSE".

Request format (dApp → mock):

1window.postMessage({
2 source: "FREIGHTER_EXTERNAL_MSG_REQUEST",
3 messageId: "<unique-id>",
4 type: "REQUEST_PUBLIC_KEY", // or SUBMIT_TRANSACTION, etc.
5 transactionXdr: "...", // only for SUBMIT_TRANSACTION
6 entryXdr: "...", // only for SUBMIT_AUTH_ENTRY
7 blob: "...", // only for SUBMIT_BLOB
8}, window.location.origin);

Response format (mock → dApp):

1window.postMessage({
2 source: "FREIGHTER_EXTERNAL_MSG_RESPONSE",
3 messagedId: "<matching-id>", // note: "messagedId" — typo in Freighter protocol
4 publicKey: "G...",
5 signedTransaction: "...",
6 // ... other fields depending on request type
7}, window.location.origin);

Note the messagedId field name — this is a typo in Freighter's actual protocol (it should be messageId). The mock reproduces this typo exactly, because freighter-api matches responses using this misspelled field. Getting it wrong would silently break response matching.

The mock handles the following message types:

Message TypePurposeResponse
REQUEST_CONNECTION_STATUSCheck if wallet is connectedisConnected: true
REQUEST_ACCESSRequest wallet accesspublicKey
REQUEST_PUBLIC_KEYGet the connected addresspublicKey
REQUEST_NETWORKGet network name and passphrasenetwork, networkPassphrase
REQUEST_NETWORK_DETAILSGet detailed network infonetwork, networkPassphrase, sorobanRpcUrl
SUBMIT_TRANSACTIONSign a transaction XDRsignedTransaction, signerAddress
SUBMIT_AUTH_ENTRYSign a Soroban auth entrysignedAuthEntry, signerAddress
SUBMIT_BLOBSign an arbitrary messagesignedMessage, signerAddress
REQUEST_ALLOWED_STATUSCheck domain allowlistisAllowed: true
SET_ALLOWED_STATUSSet domain allowlistisAllowed: true
REQUEST_USER_INFOGet user infopublicKey

localStorage Pre-Seeding

Intercepting postMessage alone isn't enough for all dApps. Libraries like @creit-tech/stellar-wallets-kit and Scaffold Stellar check localStorage on page load to determine if a wallet was previously connected. If these keys are missing, the dApp shows a “Connect Wallet” modal — which hangs headless tests because there's no user to click it.

The mock pre-seeds these localStorage keys before the dApp code runs, so the dApp boots directly into a connected state.

stellar-wallets-kit keys

@creit-tech/stellar-wallets-kit persists wallet state under these keys:

KeyValueWhy
@StellarWalletsKit/activeAddressPublic keyKit reads this to restore the active account
@StellarWalletsKit/selectedModuleId"freighter"Tells the kit which wallet module to use
@StellarWalletsKit/usedWalletsIds["freighter"]Marks Freighter as a previously-used wallet

Scaffold Stellar keys

Scaffold Stellar's WalletProvider uses its own localStorage keys (JSON-stringified via a typed storage utility):

KeyValueWhy
walletId"freighter"Identifies the connected wallet type
walletAddressPublic keyRestores the connected account address
walletNetworkNetwork nameRestores the selected network
networkPassphraseNetwork passphraseRequired for transaction signing context

If your dApp doesn't use either of these libraries, the extra localStorage keys are harmless — the core postMessage mock works regardless.

Signing in Detail

The mock exposes three signing functions from Node.js to the browser. Each handles a different type of signing operation that Freighter supports:

Transaction signing

When the dApp sends SUBMIT_TRANSACTION with a transaction XDR, the mock calls __stellarMockSignTransaction:

Node.js signing functiontypescript
1async (transactionXdr: string): Promise<string> => {
2 const kp = Keypair.fromSecret(secretKey);
3 const tx = TransactionBuilder.fromXDR(
4 transactionXdr, networkPassphrase
5 );
6 tx.sign(kp); // ed25519 signature over network hash + tx envelope
7 return tx.toXDR(); // return signed XDR back to the browser
8}

This is real cryptographic signing using @stellar/stellar-sdk. The signed transaction is valid and can be submitted to the Stellar network.

Soroban auth entry signing

When the dApp sends SUBMIT_AUTH_ENTRY, the mock calls __stellarMockSignAuthEntry:

Node.js signing functiontypescript
1async (entryXdr: string): Promise<string> => {
2 const kp = Keypair.fromSecret(secretKey);
3 // entryXdr is a HashIdPreimage XDR (base64) from authorizeEntry
4 const preimageBytes = Buffer.from(entryXdr, "base64");
5 const hash = crypto.createHash("sha256").update(preimageBytes).digest();
6 const signature = kp.sign(hash); // ed25519 sign the SHA-256 hash
7 return signature.toString("base64");
8}

Soroban auth entries require a two-step process: SHA-256 hash the HashIdPreimage XDR, then ed25519 sign the hash. This is the same process the real Freighter extension uses.

Arbitrary message signing

When the dApp sends SUBMIT_BLOB, the mock calls __stellarMockSignMessage:

Node.js signing functiontypescript
1async (message: string): Promise<string> => {
2 const kp = Keypair.fromSecret(secretKey);
3 const messageBuf = Buffer.from(message, "utf-8");
4 const signature = kp.sign(messageBuf); // ed25519 sign raw bytes
5 return signature.toString("base64");
6}

This signs arbitrary UTF-8 messages with ed25519, returning a base64-encoded signature.