Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
34e8de6
feat(builder): enable Midnight ecosystem in UI and feature flags
pasevin Oct 9, 2025
3013cf2
feat(ui): centralize Midnight icon and restore TypeScript declarations
pasevin Oct 9, 2025
bcd297c
Merge branch 'main' into feat/plat-6568-midnight-adapter-wallet-conne…
pasevin Oct 9, 2025
dada67d
Merge branch 'main' into feat/plat-6568-midnight-adapter-wallet-conne…
pasevin Oct 10, 2025
b6de87d
fix(builder): typo
pasevin Oct 10, 2025
ffe3dd9
Merge branch 'main' into feat/plat-6568-midnight-adapter-wallet-conne…
pasevin Oct 10, 2025
3d35f8b
feat(adapter-midnight): event-driven wallet with Lace implementation
pasevin Oct 11, 2025
2c0cc91
Merge branch 'main' into feat/plat-6568-midnight-adapter-wallet-conne…
pasevin Oct 11, 2025
b5378df
docs(common): finalize Midnight adapter spec, plan, checklists, and t…
pasevin Oct 12, 2025
394976a
docs(common): update stacked branch plan in spec header
pasevin Oct 12, 2025
2e9ec0e
test(adapter-midnight): add wallet connection tests, enable vitest co…
pasevin Oct 12, 2025
eab7653
chore(adapter-midnight): add changeset for wallet tests and vitest co…
pasevin Oct 12, 2025
60c2405
docs(common): update branch strategy in plan/tasks
pasevin Oct 12, 2025
86dc232
chore(deps): refresh pnpm-lock.yaml after adapter-midnight devDeps up…
pasevin Oct 12, 2025
38023db
test(builder): update ecosystem feature flag tests to include Midnigh…
pasevin Oct 12, 2025
de4cab1
chore(builder): set Midnight enabled by default in ecosystem registry
pasevin Oct 12, 2025
a4b74c5
Update packages/adapter-midnight/src/wallet/utils/SafeMidnightCompone…
pasevin Oct 14, 2025
befe221
Update packages/adapter-midnight/src/wallet/hooks/useMidnightWallet.ts
pasevin Oct 14, 2025
fabc38f
chore(adapter-midnight): formatting
pasevin Oct 14, 2025
8a925ec
chore(builder): reinstate type-check in build script
pasevin Oct 14, 2025
f02184e
docs(adapter-midnight): replace MidnightWalletProvider with MidnightW…
pasevin Oct 14, 2025
b0bd624
build(builder): add scoped typecheck script and keep build vite-only
pasevin Oct 14, 2025
a67385b
build(common): standardize on typecheck script; remove duplicate type…
pasevin Oct 14, 2025
232ceab
fix(adapter-midnight): address PR feedback — extract constants, remov…
pasevin Oct 14, 2025
e06cfc3
Midnight v1 – 02 Ingestion: contract artifacts + loader (#206)
pasevin Nov 4, 2025
2e421ea
fix(deps): override ua-parser-js to v1.0.41 to avoid AGPL license
pasevin Nov 4, 2025
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
36 changes: 36 additions & 0 deletions .changeset/midnight-wallet-event-driven-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@openzeppelin/ui-builder-adapter-midnight': minor
---

# Refactor Midnight wallet management to event-driven architecture with polling-based event emulation

**Architecture Changes:**

- Refactored wallet implementation to mirror Stellar adapter structure
- Introduced `LaceWalletImplementation` class for core wallet logic
- Added `midnightWalletImplementationManager` singleton pattern
- Created `MidnightWalletUiRoot` as the primary provider component
- Removed unnecessary `MidnightWalletProvider` wrapper for consistency
- Implemented facade functions in `connection.ts` for high-level wallet operations

**Event Emulation:**

- Lace Midnight DAppConnectorWalletAPI lacks native `onAccountChange` events
- Implemented polling-based event emulation via `api.state()` with exponential backoff
- Adaptive polling intervals: 2s initial, 5s when connected, up to 15s on errors
- Polling pauses when document is hidden (tab inactive) to reduce intrusive popups
- Polling starts only when listeners subscribe, stops when all unsubscribe

**UX Improvements:**

- Fixed repeated wallet popup issue by preventing multiple `enable()` calls
- Added `connectInFlight` guard against React Strict Mode double-effects and rapid clicks
- Implemented focus/blur heuristics to detect user dismissal of unlock popup
- 60s fallback timeout prevents infinite loading state in edge cases
- Auto-reconnect on page load for seamless UX with already-enabled wallets

**Documentation:**

- Added comprehensive inline comments explaining design decisions and limitations
- Created wallet module README documenting architecture and implementation details
- Documented all workarounds needed due to Lace API limitations
5 changes: 5 additions & 0 deletions .changeset/midnight-wallet-tests-and-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openzeppelin/ui-builder-adapter-midnight': patch
---

Add wallet connection unit tests and Vitest configuration; fix adapter imports to use local configuration barrel. Remove temporary test seam to align with other adapters.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"lint:all-fix": "pnpm -r lint:all-fix",
"lint:adapters": "pnpm --filter='./packages/adapter-*' lint:adapters",
"lint:config-files": "pnpm --filter=@openzeppelin/ui-builder-app lint:config-files",
"type-check": "pnpm -r type-check",
"typecheck": "pnpm -r typecheck",
"format": "pnpm -r format",
"format:check": "pnpm -r format:check",
"fix-all": "pnpm format && pnpm lint:fix",
Expand Down
7 changes: 2 additions & 5 deletions packages/adapter-midnight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@ This adapter has a fully implemented wallet connection feature, allowing users t

## Usage

The `MidnightAdapter` is consumed by the `builder` application's `WalletStateProvider`. It exposes a React context provider (`MidnightWalletProvider`) and a set of facade hooks that are automatically used by the main application UI.
The `MidnightAdapter` is consumed by the `builder` application's `WalletStateProvider`. It exposes a React context provider component (`MidnightWalletUiRoot`) and a set of facade hooks that are automatically used by the main application UI.

```typescript
// In the builder application's adapter factory:
import {
MidnightAdapter,
midnightTestnet,
} from '@openzeppelin/ui-builder-adapter-midnight';
import { MidnightAdapter, midnightTestnet } from '@openzeppelin/ui-builder-adapter-midnight';

const networkConfig = midnightTestnet;
const midnightAdapter = new MidnightAdapter(networkConfig);
Expand Down
5 changes: 4 additions & 1 deletion packages/adapter-midnight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"lint:adapters": "node ../../lint-adapters.cjs",
"prepublishOnly": "[ \"$SKIP_PUBLISH_BUILD\" = \"true\" ] || pnpm build",
"typecheck": "tsc --noEmit",
"test": "echo 'No tests for adapter-midnight package'"
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@midnight-ntwrk/dapp-connector-api": "^3.0.0",
Expand All @@ -51,6 +52,8 @@
"lucide-react": "^0.510.0"
},
"devDependencies": {
"vitest": "^3.2.4",
"jsdom": "^26.1.0",
"@types/react": "^19.1.9",
"eslint": "^9.32.0",
"typescript": "^5.9.2"
Expand Down
83 changes: 83 additions & 0 deletions packages/adapter-midnight/src/__tests__/wallet-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { MidnightAdapter } from '../adapter';
import { midnightTestnet } from '../networks/testnet';
import * as connection from '../wallet/connection';

vi.mock('../wallet/connection', () => {
const supportsMidnightWalletConnection = vi.fn().mockReturnValue(true);
const getMidnightAvailableConnectors = vi
.fn()
.mockResolvedValue([{ id: 'mnLace', name: 'Lace (Midnight)' }]);
const disconnectMidnightWallet = vi.fn().mockResolvedValue({ disconnected: true });
const getMidnightWalletConnectionStatus = vi
.fn()
.mockReturnValue({ isConnected: true, address: 'ct1qtestaddress', status: 'connected' });

return {
supportsMidnightWalletConnection,
getMidnightAvailableConnectors,
disconnectMidnightWallet,
getMidnightWalletConnectionStatus,
};
});

describe('MidnightAdapter Wallet Connection', () => {
const networkConfig = midnightTestnet;
let adapter: MidnightAdapter;

beforeEach(() => {
vi.clearAllMocks();
adapter = new MidnightAdapter(networkConfig);
});

it('supports wallet connection when Lace is available', () => {
expect(adapter.supportsWalletConnection()).toBe(true);
});

it('returns available connectors (Lace)', async () => {
const connectors = await adapter.getAvailableConnectors();
expect(Array.isArray(connectors)).toBe(true);
expect(connectors[0]).toEqual({ id: 'mnLace', name: 'Lace (Midnight)' });
});

it('connectWallet is not supported (use ConnectButton path)', async () => {
const result = await adapter.connectWallet('mnLace');
expect(result.connected).toBe(false);
expect(result.error).toBeDefined();
});

it('disconnects wallet via connection facade', async () => {
const res = await adapter.disconnectWallet();
expect(res.disconnected).toBe(true);
// Narrow the type to access the mocked function without using 'any'
const mocked = connection as unknown as {
disconnectMidnightWallet: (...args: unknown[]) => unknown;
};
expect(mocked.disconnectMidnightWallet).toHaveBeenCalledTimes(1);
});

it('maps wallet connection status and injects chainId', () => {
const status = adapter.getWalletConnectionStatus();
expect(status.isConnected).toBe(true);
expect(status.address).toBe('ct1qtestaddress');
expect(status.chainId).toBe(networkConfig.id);
});

it('exposes provider root, hooks facade, and wallet components', () => {
const Provider = adapter.getEcosystemReactUiContextProvider();
const hooks = adapter.getEcosystemReactHooks();
const components = adapter.getEcosystemWalletComponents();

expect(typeof Provider).toBe('function');
expect(hooks).toBeDefined();
const typedHooks = hooks as { useAccount?: unknown } | undefined;
expect(typeof (typedHooks?.useAccount as unknown as () => unknown)).toBe('function');
expect(components).toBeDefined();
const typedComponents = components as
| { ConnectButton?: unknown; AccountDisplay?: unknown }
| undefined;
expect(typeof (typedComponents?.ConnectButton as unknown as () => unknown)).toBe('function');
expect(typeof (typedComponents?.AccountDisplay as unknown as () => unknown)).toBe('function');
});
});
19 changes: 6 additions & 13 deletions packages/adapter-midnight/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import {
testMidnightRpcConnection,
validateMidnightRpcEndpoint,
} from 'packages/adapter-midnight/src/configuration';

import type {
AvailableUiKit,
Connector,
Expand All @@ -29,10 +24,11 @@ import { logger } from '@openzeppelin/ui-builder-utils';
import type { MidnightContractArtifacts } from './types/artifacts';
import { CustomAccountDisplay } from './wallet/components/account/AccountDisplay';
import { ConnectButton } from './wallet/components/connect/ConnectButton';
import { MidnightWalletProvider } from './wallet/components/MidnightWalletProvider';
import { MidnightWalletUiRoot } from './wallet/components/MidnightWalletUiRoot';
import * as connection from './wallet/connection';
import { midnightFacadeHooks } from './wallet/hooks/facade-hooks';

import { testMidnightRpcConnection, validateMidnightRpcEndpoint } from './configuration';
import { parseMidnightContractInterface, validateAndConvertMidnightArtifacts } from './utils';

/**
Expand Down Expand Up @@ -60,7 +56,7 @@ export class MidnightAdapter implements ContractAdapter {
}

public getEcosystemReactUiContextProvider(): React.FC<EcosystemReactUiProviderProps> {
return MidnightWalletProvider;
return MidnightWalletUiRoot;
}

public getEcosystemReactHooks(): EcosystemSpecificReactHooks {
Expand Down Expand Up @@ -97,13 +93,10 @@ export class MidnightAdapter implements ContractAdapter {
}

public getWalletConnectionStatus(): { isConnected: boolean; address?: string; chainId?: string } {
// This method is required by the ContractAdapter interface.
// In our React-based UI, the connection status is managed reactively by the
// MidnightWalletProvider. This function provides a non-reactive, one-time
// status check, which is not the source of truth for the UI components.
const status = connection.getMidnightWalletConnectionStatus();
return {
isConnected: false,
address: undefined,
isConnected: !!status.isConnected,
address: status.address,
chainId: this.networkConfig.id,
};
}
Expand Down
77 changes: 77 additions & 0 deletions packages/adapter-midnight/src/wallet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Midnight Adapter Wallet Module

This directory contains the wallet integration layer for the Midnight adapter, providing all wallet‑related UI, hooks, context, and utilities using the Lace Midnight wallet.

## Architectural Approach: Polling-Based Event Emulation

The Midnight adapter uses a polling-based architecture to emulate wallet events:

- The Lace Midnight wallet (`DAppConnectorWalletAPI`) does not expose native `onAccountChange` events.
- `LaceWalletImplementation` polls `api.state()` to detect account changes and emits custom events to listeners.
- Exponential backoff and visibility-aware polling minimize intrusive popups during wallet unlock.

## Purpose

- UI Environment Provision: `MidnightWalletUiRoot` provides a stable provider root for wallet state.
- Facade Hooks: `midnightFacadeHooks` expose standardized hooks for connection and account status.
- UI Components: Custom‑styled wallet components (`ConnectButton`, `AccountDisplay`).
- Event Emulation: Polling-based change detection with listener-driven polling lifecycle.

## Directory Structure

```text
wallet/
├── components/ # Wallet UI components & root
│ ├── MidnightWalletUiRoot.tsx
│ ├── account/
│ └── connect/
├── context/
├── hooks/
├── implementation/ # Lace wallet implementation
├── utils/
└── connection.ts
```

## Key Components & Concepts

- Adapter UI methods:
- `getEcosystemReactUiContextProvider()` → `MidnightWalletUiRoot`
- `getEcosystemReactHooks()` → `midnightFacadeHooks`
- `getEcosystemWalletComponents()` → custom components
- `midnightWalletImplementationManager`: Singleton controller for wallet implementation instance
- `MidnightWalletUiRoot`: Stable provider that wires the implementation and context
- Facade hooks: `useMidnightAccount`, `useMidnightConnect`, `useMidnightDisconnect`

## Key Characteristics

- Single wallet support (Lace Midnight only)
- Polling-based event emulation (no native event handlers)
- Focus/blur heuristics for dismissal detection (no native `onDismiss` event)
- No network switching component (Midnight wallets do not switch networks dynamically)
- Custom UI components matching the Builder's design system

## Implementation Details

### Event Emulation

- `LaceWalletImplementation.onWalletConnectionChange()` emulates native events.
- Polling starts when listeners subscribe, stops when all listeners unsubscribe.
- Adaptive polling intervals: 2s initial, 5s when connected, up to 15s exponential backoff on errors.
- Pauses polling when document is hidden (tab inactive) to reduce intrusive popups.

### Dismissal Detection

- Wallet popup does not expose `onDismiss` or `onReject` events.
- `MidnightWalletUiRoot` monitors `window.focus` events to infer dismissal.
- When focus returns and no address is set, UI stops "Connecting…" and unsubscribes (stops polling).
- 60s fallback timeout prevents infinite loading in edge cases.

### Multiple Popup Prevention

- `LaceWalletImplementation.connect()` uses `connectInFlight` guard to prevent multiple `enable()` calls.
- React Strict Mode double-effects and rapid button clicks are handled gracefully.
- No immediate `api.state()` reads after `enable()` to avoid re-prompting locked wallets.

## Usage in Application

`MidnightWalletUiRoot` is returned by the adapter and used by the Builder's `WalletStateProvider`. Use `useWalletState()` and facade hooks from `@openzeppelin/ui-builder-react-core` to render the wallet UI components from `getEcosystemWalletComponents()`.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
} from '@midnight-ntwrk/dapp-connector-api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import * as implementation from '../midnight-implementation';
import { LaceWalletImplementation } from '../implementation/lace-implementation';

const mockApi: DAppConnectorWalletAPI = {
state: vi.fn().mockResolvedValue({ address: 'test-address' }),
Expand All @@ -26,6 +26,7 @@ const mockLace: DAppConnectorAPI = {
const originalWindow = global.window;

describe('Midnight Wallet Implementation', () => {
let impl: LaceWalletImplementation;
beforeEach(() => {
// @ts-expect-error - We are intentionally overwriting the global window for tests
global.window = {
Expand All @@ -34,29 +35,34 @@ describe('Midnight Wallet Implementation', () => {
},
};
vi.clearAllMocks();
impl = new LaceWalletImplementation();
});

afterEach(() => {
// Restore the original window object
global.window = originalWindow;
implementation.disconnect();
impl.disconnect();
});

it('should call the enable method on the Lace wallet API', async () => {
const api = await implementation.connect();
const result = await impl.connect();
expect(mockLace.enable).toHaveBeenCalledTimes(1);
expect(api).toBe(mockApi);
expect(result.connected).toBe(true);
});

it('should throw an error if the wallet is not found', async () => {
it('should return an error if the wallet is not found', async () => {
// @ts-expect-error - We are deleting a property from the mock window for this test case
delete global.window.midnight.mnLace;
await expect(implementation.connect()).rejects.toThrow('Lace wallet not found.');
const result = await impl.connect();
expect(result.connected).toBe(false);
expect(result.error).toBe('Lace wallet not found.');
});

it('should re-throw an error if the connection is rejected', async () => {
it('should return an error if the connection is rejected', async () => {
const rejectionError = new Error('User rejected.');
vi.spyOn(mockLace, 'enable').mockRejectedValueOnce(rejectionError);
await expect(implementation.connect()).rejects.toThrow(rejectionError);
const result = await impl.connect();
expect(result.connected).toBe(false);
expect(result.error).toBe('User rejected.');
});
});
Loading
Loading