Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/browser-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ its CLI/tools. It does not own or serve the browser bridge.

Unsupported methods return an EIP-1193-style error object with code `4200`.

`personal_sign`, `eth_sendTransaction`, broad signing, hardware wallets, Kohaku, native messaging, and
store distribution are intentionally not implemented in this PR.
The injected provider also exposes `isConnected()`, `on`, and `removeListener` with a minimal event
surface for `accountsChanged`, `chainChanged`, `connect`, and `disconnect`.

`personal_sign`, `eth_sendTransaction`, broad signing, hardware wallets, native messaging, and store
distribution are intentionally not implemented in this bridge slice. Kohaku remains useful for
wallet-internal provider/backend integration, not as the browser-facing dapp provider.

## Dapp sessions

Expand Down
69 changes: 65 additions & 4 deletions extension/injected.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
(() => {
let nextId = 1;
const pending = new Map();
const listeners = new Map();

class DeckardProvider {
constructor() {
this.isDeckard = true;
this.selectedAddress = null;
this.chainId = null;
this._connected = true;
}

request(args) {
Expand All @@ -29,12 +31,29 @@
});
}

on() {
// Event emitter support is intentionally out of scope for this milestone.
isConnected() {
return this._connected;
}

on(eventName, listener) {
if (typeof listener !== 'function') {
return this;
}
const eventListeners = listeners.get(eventName) ?? new Set();
eventListeners.add(listener);
listeners.set(eventName, eventListeners);
return this;
}

removeListener() {
removeListener(eventName, listener) {
const eventListeners = listeners.get(eventName);
if (!eventListeners) {
return this;
}
eventListeners.delete(listener);
if (eventListeners.size === 0) {
listeners.delete(eventName);
}
return this;
}
}
Expand All @@ -45,6 +64,22 @@
return error;
}

function emit(eventName, payload) {
const eventListeners = listeners.get(eventName);
if (!eventListeners) {
return;
}
for (const listener of [...eventListeners]) {
try {
listener(payload);
} catch (error) {
setTimeout(() => {
throw error;
});
}
}
}

const provider = new DeckardProvider();

window.addEventListener('message', (event) => {
Expand All @@ -57,15 +92,41 @@
pending.delete(message.id);

if (message.error) {
if (message.error.code === 4900 && provider._connected) {
provider._connected = false;
emit('disconnect', providerError(4900, message.error.message));
}
entry.reject(providerError(message.error.code, message.error.message));
return;
}

if (!provider._connected) {
provider._connected = true;
emit('connect', { chainId: provider.chainId });
}

if (entry.method === 'eth_chainId') {
const previousChainId = provider.chainId;
provider.chainId = message.result;
if (previousChainId && previousChainId !== message.result) {
emit('chainChanged', message.result);
}
}
if (entry.method === 'eth_requestAccounts' && Array.isArray(message.result)) {
provider.selectedAddress = message.result[0] || null;
const previousAddress = provider.selectedAddress;
const nextAddress = message.result[0] || null;
provider.selectedAddress = nextAddress;
if (previousAddress !== nextAddress) {
emit('accountsChanged', message.result);
}
}
if (entry.method === 'eth_accounts' && Array.isArray(message.result)) {
const previousAddress = provider.selectedAddress;
const nextAddress = message.result[0] || null;
provider.selectedAddress = nextAddress;
if (previousAddress !== nextAddress) {
emit('accountsChanged', message.result);
}
}
entry.resolve(message.result);
});
Expand Down
18 changes: 18 additions & 0 deletions tests/extension/browser-bridge-extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,40 @@ test('local dapp can connect through the injected provider', async ({ page }, te
if (!provider) {
throw new Error('window.ethereum missing');
}
const events: Array<{ name: string; payload: unknown }> = [];
const removedEvents: unknown[] = [];
const removedListener = (payload: unknown) => removedEvents.push(payload);
provider.on('accountsChanged', (payload) => events.push({ name: 'accountsChanged', payload }));
provider.on('accountsChanged', removedListener);
provider.removeListener('accountsChanged', removedListener);
const accountsBefore = await provider.request({ method: 'eth_accounts' });
const connectedBefore = provider.isConnected();
const requestAccounts = await provider.request({ method: 'eth_requestAccounts' });
const accountsAfter = await provider.request({ method: 'eth_accounts' });
const chainId = await provider.request({ method: 'eth_chainId' });
return {
accountsBefore,
connectedBefore,
requestAccounts,
accountsAfter,
chainId,
connectedAfter: provider.isConnected(),
events,
removedEvents,
isDeckard: Boolean(provider.isDeckard),
selectedAddress: provider.selectedAddress,
};
});

expect(providerState).toEqual({
accountsBefore: [],
connectedBefore: true,
requestAccounts: [mockAccount],
accountsAfter: [mockAccount],
chainId: '0xaa36a7',
connectedAfter: true,
events: [{ name: 'accountsChanged', payload: [mockAccount] }],
removedEvents: [],
isDeckard: true,
selectedAddress: mockAccount,
});
Expand All @@ -70,6 +85,9 @@ declare global {
ethereum?: {
isDeckard?: boolean;
selectedAddress?: string | null;
isConnected(): boolean;
on(eventName: string, listener: (payload: unknown) => void): Window['ethereum'];
removeListener(eventName: string, listener: (payload: unknown) => void): Window['ethereum'];
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
};
}
Expand Down
65 changes: 64 additions & 1 deletion tests/extension/real-daemon.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,40 @@ test('local dapp can connect through the extension to a real unlocked daemon', a
if (!provider) {
throw new Error('window.ethereum missing');
}
const events: Array<{ name: string; payload: unknown }> = [];
const removedEvents: unknown[] = [];
const removedListener = (payload: unknown) => removedEvents.push(payload);
provider.on('accountsChanged', (payload) => events.push({ name: 'accountsChanged', payload }));
provider.on('accountsChanged', removedListener);
provider.removeListener('accountsChanged', removedListener);
const accountsBefore = await provider.request({ method: 'eth_accounts' });
const connectedBefore = provider.isConnected();
const requestAccounts = await provider.request({ method: 'eth_requestAccounts' });
const accountsAfter = await provider.request({ method: 'eth_accounts' });
const chainId = await provider.request({ method: 'eth_chainId' });
return {
accountsBefore,
connectedBefore,
requestAccounts,
accountsAfter,
chainId,
connectedAfter: provider.isConnected(),
events,
removedEvents,
isDeckard: Boolean(provider.isDeckard),
selectedAddress: provider.selectedAddress,
};
});

expect(providerState).toEqual({
accountsBefore: [],
connectedBefore: true,
requestAccounts: [realDaemonAccount],
accountsAfter: [realDaemonAccount],
chainId: realDaemonChainId,
connectedAfter: true,
events: [{ name: 'accountsChanged', payload: [realDaemonAccount] }],
removedEvents: [],
isDeckard: true,
selectedAddress: realDaemonAccount,
});
Expand Down Expand Up @@ -86,7 +101,7 @@ for (const failure of [
message: 'different chain',
},
]) {
test(`real daemon bridge reports ${failure.name}`, async ({}, testInfo) => {
test(`real daemon bridge reports ${failure.name}`, async ({ page }, testInfo) => {
const runtime = await startRealDaemonBridge(failure.scenario, testInfo.title);
try {
const chainId = await requestBridge('eth_chainId', 1);
Expand All @@ -101,6 +116,51 @@ for (const failure of [
code: 4900,
});
expect(requestAccounts.error.message).toContain(failure.message);

await page.goto('/');
await expect(page.locator('#output')).toContainText('window.ethereum detected');

const providerState = await page.evaluate(async () => {
const provider = window.ethereum;
if (!provider) {
throw new Error('window.ethereum missing');
}
const disconnects: Array<{ code?: number; message?: string }> = [];
provider.on('disconnect', (error) => {
const providerError = error as Error & { code?: number };
disconnects.push({
code: error instanceof Error ? providerError.code : undefined,
message: error instanceof Error ? providerError.message : undefined,
});
});
let requestError: { code?: number; message?: string } | undefined;
try {
await provider.request({ method: 'eth_requestAccounts' });
} catch (error) {
const providerError = error as Error & { code?: number };
requestError = {
code: error instanceof Error ? providerError.code : undefined,
message: error instanceof Error ? providerError.message : undefined,
};
}
return {
connected: provider.isConnected(),
disconnects,
requestError,
};
});

expect(providerState.connected).toBe(false);
expect(providerState.disconnects).toEqual([
expect.objectContaining({
code: 4900,
}),
]);
expect(providerState.disconnects[0]?.message).toContain(failure.message);
expect(providerState.requestError).toMatchObject({
code: 4900,
});
expect(providerState.requestError?.message).toContain(failure.message);
} finally {
await runtime.stop();
}
Expand All @@ -112,6 +172,9 @@ declare global {
ethereum?: {
isDeckard?: boolean;
selectedAddress?: string | null;
isConnected(): boolean;
on(eventName: string, listener: (payload: unknown) => void): Window['ethereum'];
removeListener(eventName: string, listener: (payload: unknown) => void): Window['ethereum'];
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
};
}
Expand Down
2 changes: 1 addition & 1 deletion wallet-relevant-eips.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ browser-extension architecture, not as standards targets.
| Category | Title | Source | Wallet relevance | Implementation importance | Additional notes |
| --- | --- | --- | --- | --- | --- |
| Architecture | Privacy-first Ethereum tooling | [kohaku/README.md](https://github.com/ethereum/kohaku/blob/master/README.md) | Positions Kohaku as reusable wallet infrastructure for privacy-preserving Ethereum UX. | High | Production integration still needs security review for each reused package. |
| Dapp bridge | EIP-1193 provider abstraction | [kohaku/crates/eip-1193-provider/README.md](https://github.com/ethereum/kohaku/blob/master/crates/eip-1193-provider/README.md), [kohaku/packages/provider/README.md](https://github.com/ethereum/kohaku/blob/master/packages/provider/README.md) | Useful for keeping the browser extension provider thin while routing requests into native, WASM, Ethers, Viem, Helios, or Colibri backends. | Critical | Fits the desktop-wallet model where the extension is only the dapp transport and the desktop app owns policy, accounts, and signing. |
| Provider/backend abstraction | Kohaku EIP-1193-style provider seam | [kohaku/crates/eip-1193-provider/README.md](https://github.com/ethereum/kohaku/blob/master/crates/eip-1193-provider/README.md), [kohaku/packages/provider/README.md](https://github.com/ethereum/kohaku/blob/master/packages/provider/README.md) | Useful for routing wallet-internal reads into native, WASM, Ethers, Viem, Helios, or Colibri backends. | Critical | This is not a drop-in browser wallet provider; Deckard still owns the injected dapp boundary, origin permissions, events, policy, accounts, and signing. |
| Privacy infrastructure | Private RPC and light-client posture | [kohaku/docs/pages/practices.mdx](https://github.com/ethereum/kohaku/blob/master/docs/pages/practices.mdx), [kohaku/docs/pages/privacy.mdx](https://github.com/ethereum/kohaku/blob/master/docs/pages/privacy.mdx) | Reduces wallet activity leakage from default RPCs and centralized indexing. | High | Kohaku docs call out user-defined RPCs, Helios-style verification, and optional network privacy routing as wallet best practices. |
| Account UX | Many accounts, many identities | [kohaku/docs/pages/practices.mdx](https://github.com/ethereum/kohaku/blob/master/docs/pages/practices.mdx), [kohaku/docs/pages/privacy.mdx](https://github.com/ethereum/kohaku/blob/master/docs/pages/privacy.mdx) | Encourages per-context accounts and easy account creation during dapp connection. | High | This should influence the extension connect flow: choosing or creating an account should be part of the permission grant, not only a global wallet setting. |
| Plugin system | Standardized private-transaction plugin interface | [kohaku/packages/plugins/README.md](https://github.com/ethereum/kohaku/blob/master/packages/plugins/README.md) | Lets the wallet expose shield, transfer, unshield, balance, and broadcast flows across multiple privacy protocols through one host interface. | High | Host responsibilities include storage, network fetch, keystore derivation, and Ethereum provider access; this maps cleanly to a desktop wallet core. |
Expand Down
Loading