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:
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.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 | |
|---|---|---|
| Protocol | EIP-1193 / EIP-6963 provider | Freighter window.postMessage |
| Signing | viem WalletClient in Node.js | @stellar/stellar-sdk Keypair in Node.js |
| Discovery | Dispatches eip6963:announceProvider event | Sets window.freighter = true + pre-seeds localStorage |
| Exposed function | eip1193Request (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 useswindow.postMessagewith specific source strings and message types. The mock replaces the EIP-6963 event dispatch with apostMessagelistener. - Signing — wallet-mock uses
viemfor Ethereum signing (secp256k1). stellar-wallet-mock uses@stellar/stellar-sdkfor Stellar signing (ed25519), including the Stellar-specific transaction hashing (network passphrase + envelope hash). - Discovery — Ethereum wallets announce themselves via
eip6963:announceProviderevents. Freighter signals its presence by settingwindow.freighter = true. The mock also pre-seedslocalStoragefor Stellar-specific libraries, which Ethereum wallets don't need. - Function granularity — wallet-mock exposes a single
eip1193Requestfunction 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 functionsThe mock operates at the window.postMessage layer — the universal protocol that all Freighter integrations use. This means it works transparently with:
@stellar/freighter-apidirectly@creit-tech/stellar-wallets-kitwith FreighterModule- Scaffold Stellar apps
- Any code that sends raw
postMessagecalls 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:
1await page.exposeFunction(2 "__stellarMockSignTransaction",3 async (transactionXdr: string): Promise<string> => {4 // This runs in Node.js — stellar-sdk is available here5 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:
1// Inside the injected browser script2const signedXdr = await window.__stellarMockSignTransaction(3 data.transactionXdr4);When the browser calls window.__stellarMockSignTransaction(), Playwright:
- Sends the arguments from the browser process to the Node.js process
- Executes the Node.js function (where
stellar-sdksigns the transaction) - Sends the return value back to the browser
- 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:
1// config is serialized to JSON and passed into the browser2await 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:
- Sets
window.freighter = true— this is howfreighter-api'sisConnected()detects that the extension is present. Without it, the dApp short-circuits and never sends any messages. - Pre-seeds localStorage — libraries like
@creit-tech/stellar-wallets-kitand 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). - Listens for
window.postMessageevents — intercepts every message withsource: "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_TRANSACTION6 entryXdr: "...", // only for SUBMIT_AUTH_ENTRY7 blob: "...", // only for SUBMIT_BLOB8}, window.location.origin);Response format (mock → dApp):
1window.postMessage({2 source: "FREIGHTER_EXTERNAL_MSG_RESPONSE",3 messagedId: "<matching-id>", // note: "messagedId" — typo in Freighter protocol4 publicKey: "G...",5 signedTransaction: "...",6 // ... other fields depending on request type7}, 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 Type | Purpose | Response |
|---|---|---|
REQUEST_CONNECTION_STATUS | Check if wallet is connected | isConnected: true |
REQUEST_ACCESS | Request wallet access | publicKey |
REQUEST_PUBLIC_KEY | Get the connected address | publicKey |
REQUEST_NETWORK | Get network name and passphrase | network, networkPassphrase |
REQUEST_NETWORK_DETAILS | Get detailed network info | network, networkPassphrase, sorobanRpcUrl |
SUBMIT_TRANSACTION | Sign a transaction XDR | signedTransaction, signerAddress |
SUBMIT_AUTH_ENTRY | Sign a Soroban auth entry | signedAuthEntry, signerAddress |
SUBMIT_BLOB | Sign an arbitrary message | signedMessage, signerAddress |
REQUEST_ALLOWED_STATUS | Check domain allowlist | isAllowed: true |
SET_ALLOWED_STATUS | Set domain allowlist | isAllowed: true |
REQUEST_USER_INFO | Get user info | publicKey |
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:
| Key | Value | Why |
|---|---|---|
@StellarWalletsKit/activeAddress | Public key | Kit 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):
| Key | Value | Why |
|---|---|---|
walletId | "freighter" | Identifies the connected wallet type |
walletAddress | Public key | Restores the connected account address |
walletNetwork | Network name | Restores the selected network |
networkPassphrase | Network passphrase | Required 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:
1async (transactionXdr: string): Promise<string> => {2 const kp = Keypair.fromSecret(secretKey);3 const tx = TransactionBuilder.fromXDR(4 transactionXdr, networkPassphrase5 );6 tx.sign(kp); // ed25519 signature over network hash + tx envelope7 return tx.toXDR(); // return signed XDR back to the browser8}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:
1async (entryXdr: string): Promise<string> => {2 const kp = Keypair.fromSecret(secretKey);3 // entryXdr is a HashIdPreimage XDR (base64) from authorizeEntry4 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 hash7 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:
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 bytes5 return signature.toString("base64");6}This signs arbitrary UTF-8 messages with ed25519, returning a base64-encoded signature.