@sponsorcoin/merit-connect 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Account states: the caller keeps the remote accounts as CONNECTED or DISCONNECTED (`getDisconnected` / `setDisconnected`, `setAccountConnected`, `listAccounts`). A disconnect made by the caller keeps the account listed; only the remote removes an account (the handshake removes what the remote disconnected, adds what it connected, and lets the caller's own disconnect win). New call `merit_syncAccounts`: the caller sends its connected list and the remote connects exactly those that exist on it. The wallet-level `revoke` now turns every connected account into a kept, disconnected one. 17 tests, 7 mutants.
6
+ - Opening a selection is a SYNC: `eth_requestAccounts` carries the list of accounts the caller wants connected; the remote sets exactly those that exist on it to connected before the selector opens and leaves every other account disconnected. A caller that sends no list changes nothing. 3 tests, 1 more mutant, 1 live check.
7
+ - Connection monitor (`createConnectionMonitor`, `describeConnection`): one subscribable store for the link, with the reason it is not connected (`detached`, `page-silent`, `no-page`, `unknown`) and `waitForConnected`. 15 tests, 6 more mutants.
8
+
9
+ ## 0.0.1 (2026-11-06)
10
+
11
+ First version, protocol version 1.
12
+
13
+ - Messages, version check and parsing (`protocol`): one error table including 4901 (version not supported).
14
+ - Requester hub (`createRelay`): one page per request, an answer only from the page and origin asked, once, in time; selection sessions that stay open for pushes; every drop traced with its reason.
15
+ - Responder (`createResponder`): the account selection (starts from what the requester holds, the first change answers, later changes are pushed, a close ends it), revoke, the shared-account check on every signature, one request at a time.
16
+ - Standard calls (`createMeritConnect`): `fetchMeritConnections`, `requestAccounts`, `handshake` (pulled, waits for the link, keeps the stored copy on failure), `revoke`, `onConnectionChange`.
17
+ - Adapters for the Chrome extension and the web page (`createWorkerHub`, `createPanelRequest`, `attachWebPageResponder`), and the in-memory `createLoopback`.
18
+ - One trace channel, `merit-connect:<event>`, with the request id on every line.
19
+ - 74 unit tests, 17 adapter and chain tests, 26 mutants (all caught), and a live end-to-end test (`test/live`).
package/README.md CHANGED
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @sponsorcoin/merit-connect
2
+
3
+ The protocol one Merit wallet uses to ask another Merit wallet who is connected, for its accounts, and for signatures. Zero dependencies: no React, no `window`, no `chrome`. The transport (Chrome extension messaging, a web page, the in-memory loopback) is injected.
4
+
5
+ Design, contract, error codes and the build rows: `docs/design/meritConnectDesign.txt` of the web app repo.
6
+
7
+ Status (2026-11-06): in use. The Merit Chrome extension and the Merit web app both run on it (the extension's worker relay and side-panel authenticator, the web app's responder). The protocol is version 1; the API below is the one `0.x` will keep stable. See `CHANGELOG.md`.
8
+
9
+ Build: `npm run build` (tsc, output in `dist/`, which is tracked like the other packages). Test: `npm test` (builds, runs the five test files and the mutation check: every mutant, a deliberately broken rule in a copy of the build, must be caught). Live: `test/live/meritConnect.live.cjs` runs the real extension and the real web app in a real Chromium (its header lists what it needs). Example: `node examples/loopback.mjs`, a complete holder and asker over the in-memory loopback.
10
+
11
+ ## The calls
12
+
13
+ ```ts
14
+ import { createMeritConnect } from '@sponsorcoin/merit-connect';
15
+
16
+ const connect = createMeritConnect({ WEB_APP: { request, link, subscribeLink, getShared, setShared, newRid, setTimer, clearTimer, now, trace } });
17
+
18
+ await connect.fetchMeritConnections('WEB_APP'); // { side, connected, loggedOn, detached, shared }
19
+ await connect.requestAccounts('WEB_APP'); // opens the other wallet's account selector
20
+ await connect.handshake('WEB_APP'); // pulls the other wallet's shared list, keeps it
21
+ await connect.revoke('WEB_APP'); // disconnects from that wallet's accounts
22
+ ```
23
+
24
+ `side` is `'EXT'` or `'WEB_APP'`: the wallet being asked. The other wallet is the authority for its own shared list; the copy a wallet keeps is corrected by a pull (`handshake`), never by an unasked push.
25
+
26
+ ## The halves
27
+
28
+ | Piece | Where it runs | What it does |
29
+ | --- | --- | --- |
30
+ | `createRelay` | the requester's hub (the extension's worker) | sends a request to ONE page, takes an answer only from that page and origin, once, in time; opens a selection session; takes pushes only while it is open |
31
+ | `createResponder` | the wallet that holds the accounts (the web page) | answers reads, runs the account selection, refuses what is not allow-listed, checks that a signing account is shared |
32
+ | `createMeritConnect` | the wallet that asks | the standard calls above, over one `request` function |
33
+ | `createLoopback` | tests | wires a relay to a responder in memory; can drop, duplicate, delay and mislabel messages |
34
+ | `createWorkerHub`, `createPanelRequest`, `attachWebPageResponder` | the Chrome extension and the web page | the adapters (`src/transports.ts`): the worker's relay wired to the tabs, the side panel's request over the worker, the page's responder wired to its provider |
35
+
36
+ ## Writing your own transport
37
+
38
+ The cores never touch a browser API. A host supplies the channel and says who the SENDER is as its own platform knows it (the browser's tab and origin, never the message's own words):
39
+
40
+ - the requester's hub needs `targets()` (the pages it can ask), `send(targetId, message)`, timers, an id source and `now()`; it hands every incoming reply to `relay.accept(sender, payload)` and every selection push to `relay.acceptPush(sender, payload)`;
41
+ - the responder needs `reply(reply)` and `push(push)` to send, `handlers` (what the wallet knows and does), and is fed requests with `responder.handle(data)`;
42
+ - the client calls need one `request(method, params, rid)` function that resolves with a reply.
43
+
44
+ `test/transports.test.mjs` is a complete worked example with plain objects standing in for the Chrome APIs.
45
+
46
+ ## The protocol in short
47
+
48
+ A message carries `v` (the version, 1) and `rid` (the request id, 1 to 64 letters, digits, `-` and `_`). Methods: `eth_chainId`, `eth_accounts`, `wallet_revokePermissions` (reads, 20 s), `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4`, `eth_sendTransaction` (approvals, 5 min). Errors: 4001 rejected, 4100 not authorised, 4200 unsupported, 4900 unavailable, 4901 version not supported, -32602 invalid params, -32005 too many pending. The full contract, the authority and sync rules and the security rules: `docs/design/meritConnectDesign.txt`.
49
+
50
+ ## Account states and the sync call
51
+
52
+ The caller keeps the remote's accounts as CONNECTED or DISCONNECTED (give `getDisconnected` / `setDisconnected` to keep the disconnected ones). The rules:
53
+
54
+ - **Connect** (the caller presses it): the caller's connected accounts are sent (`merit_syncAccounts`, or inside `eth_requestAccounts` when a selector is opened); the remote connects exactly those that exist on it and every other account there stays disconnected.
55
+ - **The caller changes an account** (`setAccountConnected`): the account STAYS in the caller's list and only its state changes. `revoke` (the wallet-level Disconnect) turns every connected account into a disconnected one that stays listed.
56
+ - **The remote changes an account**: when it disconnects one, the caller REMOVES it from its list (the remote owns the accounts); when it connects one, the caller ADDS it. Pushes (while a selector is open) and the handshake apply this.
57
+ - The caller's own disconnect wins if the remote was away: the handshake tells the remote.
58
+
59
+ ## The connection monitor
60
+
61
+ `createConnectionMonitor` is a plain store (`subscribe`, `getSnapshot`; it drops into `useSyncExternalStore`) that says whether the other wallet is connected and, when it is not, WHY in a sentence a person can act on:
62
+
63
+ | state | meaning | reason shown |
64
+ | --- | --- | --- |
65
+ | `connected` | a page answers and the person has not disconnected it | |
66
+ | `detached` | the person disconnected it on purpose (that choice wins) | "You disconnected ... Press Connect to use it again." |
67
+ | `page-silent` | a page is open but does not answer (a reloaded extension leaves its content script dead) | "... Reload that page (F5) and try again." |
68
+ | `no-page` | no page is open | "... Open it, or press Connect to open it." |
69
+ | `unknown` | the probe itself could not be asked | "... Reload the extension and try again." |
70
+
71
+ The host supplies one probe, `{ reporting, candidates, loggedOn }` (how many pages answered, how many pages that could be it are open and did NOT, whether the answering one is logged on). `waitForConnected(timeoutMs)` resolves as soon as it connects (asking again every 2 s, so a page the person reloads meanwhile is noticed) or at the limit with the current reason.
72
+
73
+ ## Trace
74
+
75
+ Every step calls the host's trace function with `merit-connect:<event>` and a detail that carries the request id (`rid`): `request`, `sent`, `received`, `answered`, `failed`, `refused` (with the reason), `selection.open`, `selection.change`, `selection.push`, `selection.close`, `handshake`, `revoke`. A request that was dropped is an id with no later event; a mismatched one is a `refused` event that names the sender.
@@ -0,0 +1,83 @@
1
+ import { type Reply, type Side } from './protocol.js';
2
+ import { type TraceFn } from './trace.js';
3
+ /** How a host asks the other wallet: the relay's `request`, reached however the host reaches it. Always resolves with a reply. */
4
+ export type RequestFn = (method: string, params: unknown, rid: string) => Promise<Reply>;
5
+ export interface LinkState {
6
+ /** The other wallet answers and the person has not disconnected it. */
7
+ connected: boolean;
8
+ loggedOn: boolean;
9
+ detached: boolean;
10
+ }
11
+ export interface SideConfig {
12
+ request: RequestFn;
13
+ /** The link as it reads now. It reads "not connected" until its first answer comes back. */
14
+ link(): LinkState;
15
+ /** Called when the link comes up or goes down. Returns the unsubscribe function. */
16
+ subscribeLink(listener: () => void): () => void;
17
+ /** The copy of the other wallet's shared accounts that this wallet keeps. */
18
+ getShared(): Promise<string[]> | string[];
19
+ setShared(accounts: string[]): Promise<void> | void;
20
+ /**
21
+ * 2026-11-06 (owner rule): the accounts of the other wallet that this wallet KEEPS in its list but has disconnected. When the caller changes an account's state the account stays listed and only its state changes; only the OTHER wallet
22
+ * (by disconnecting it there) removes an account from this list. A host that does not keep such a list leaves these out: a disconnect then removes the account, as before.
23
+ */
24
+ getDisconnected?(): Promise<string[]> | string[];
25
+ setDisconnected?(accounts: string[]): Promise<void> | void;
26
+ newRid(): string;
27
+ setTimer(fn: () => void, ms: number): unknown;
28
+ clearTimer(handle: unknown): void;
29
+ now(): number;
30
+ trace?: TraceFn;
31
+ /** 2026-11-06 (owner): a message for the person when the OTHER wallet took accounts out of this list ("Disconnected by Remote", "Account Does not Exist on ..., Removed from Local"). The host shows it as a notice. */
32
+ notify?(message: string, addresses: string[]): void;
33
+ }
34
+ export interface Connections {
35
+ side: Side;
36
+ connected: boolean;
37
+ loggedOn: boolean;
38
+ detached: boolean;
39
+ shared: string[];
40
+ }
41
+ export interface HandshakeResult {
42
+ /** The copy was different and was replaced. */
43
+ changed: boolean;
44
+ /** The accounts connected here and there after the handshake. */
45
+ shared: string[];
46
+ /** Accounts kept in the list but disconnected here (empty when the host keeps no such list). */
47
+ disconnected?: string[];
48
+ /** Why no answer was asked for (the stored copy was kept). */
49
+ skipped?: string;
50
+ }
51
+ /** How long a list that loads while the link still reads "not connected" waits for the link before it shows what it has. */
52
+ export declare const LINK_WAIT_MS = 1500;
53
+ /** How long the handshake waits for the other wallet's answer. */
54
+ export declare const HANDSHAKE_MS = 2500;
55
+ /** A handshake asked this recently is not asked again (the list loads often). */
56
+ export declare const HANDSHAKE_MIN_GAP_MS = 1500;
57
+ /** One account of the other wallet as this wallet lists it: connected or disconnected (kept, not removed). */
58
+ export interface ListedAccount {
59
+ address: string;
60
+ connected: boolean;
61
+ }
62
+ /** The standard calls, for the sides this host can reach. A side that was not configured throws 4900. */
63
+ export declare function createMeritConnect(sides: Partial<Record<Side, SideConfig>>): {
64
+ fetchMeritConnections: (side: Side, options?: {
65
+ refresh?: boolean;
66
+ }) => Promise<Connections>;
67
+ requestAccounts: (side: Side) => Promise<string[]>;
68
+ /** Connect this wallet to the other: its own Connect page (Cancel / Connect), no accounts. */
69
+ requestConnection: (side: Side) => Promise<void>;
70
+ handshake: (side: Side, options?: {
71
+ force?: boolean;
72
+ }) => Promise<HandshakeResult>;
73
+ revoke: (side: Side) => Promise<void>;
74
+ /** Connect or disconnect ONE account from this wallet (a disconnected account stays in the list). */
75
+ setAccountConnected: (side: Side, address: string, connected: boolean) => Promise<void>;
76
+ /** Remove ONE account of the other wallet from this wallet's list (the other wallet is told to disconnect it first and keeps the account). */
77
+ forgetAccount: (side: Side, address: string) => Promise<void>;
78
+ /** The accounts as this wallet lists them, each connected or disconnected. */
79
+ listAccounts: (side: Side) => Promise<ListedAccount[]>;
80
+ /** The link to that side came up or went down: the host re-runs its list load (which runs the handshake). */
81
+ onConnectionChange: (side: Side, listener: () => void) => () => void;
82
+ };
83
+ export type MeritConnect = ReturnType<typeof createMeritConnect>;
package/dist/client.js ADDED
@@ -0,0 +1,299 @@
1
+ // File: src/client.ts
2
+ //
3
+ // 2026-11-06 (docs/design/meritConnectDesign.txt, rows M6 and section 7): the standard calls a wallet makes to ask another Merit wallet who is connected, for its accounts, and to disconnect. They run over ONE function the
4
+ // host supplies (`request`: in the Chrome extension a message to the worker's relay), so the calls are the same in every app. The side asked is 'EXT' or 'WEB_APP'.
5
+ //
6
+ // The copy of the other wallet's shared list that this wallet keeps is for DISPLAY. The other wallet is the authority (design section 6), so the copy is corrected by a PULL (handshake), never by an unasked push.
7
+ import { ERROR, MeritConnectError, isAddress } from './protocol.js';
8
+ import { createTrace } from './trace.js';
9
+ /** How long a list that loads while the link still reads "not connected" waits for the link before it shows what it has. */
10
+ export const LINK_WAIT_MS = 1500;
11
+ /** How long the handshake waits for the other wallet's answer. */
12
+ export const HANDSHAKE_MS = 2500;
13
+ /** A handshake asked this recently is not asked again (the list loads often). */
14
+ export const HANDSHAKE_MIN_GAP_MS = 1500;
15
+ const sameList = (a, b) => a.length === b.length && a.every((x) => b.some((y) => y.toLowerCase() === x.toLowerCase()));
16
+ const has = (list, address) => list.some((a) => a.toLowerCase() === address.toLowerCase());
17
+ const without = (list, remove) => list.filter((a) => !has(remove, a));
18
+ const union = (a, b) => [...a, ...b.filter((x) => !has(a, x))];
19
+ function createSideClient(side, config) {
20
+ const trace = createTrace(config.trace);
21
+ let lastHandshakeAt = Number.NEGATIVE_INFINITY;
22
+ // 2026-11-06 (owner): this wallet's connected accounts are kept (persisted) while the link is down, and the remote disconnects ALL of its accounts then. So the FIRST handshake after a start, a disconnect or a lost link does not pull: it
23
+ // RE-SENDS this wallet's connected list (merit_syncAccounts), which re-establishes the connection states. An account the remote does not have is removed here, with a notice.
24
+ let needsResync = true;
25
+ // a state change made here is being sent: the list reloads it causes must not pull (the other wallet has not heard of it yet, and would bring the account back)
26
+ let changing = 0;
27
+ config.subscribeLink(() => {
28
+ if (!config.link().connected)
29
+ needsResync = true;
30
+ });
31
+ const appName = side === 'EXT' ? 'Merit (Ext)' : 'Merit (Web App)';
32
+ const announce = (addresses, why) => {
33
+ config.notify?.(why === 'missing' ? `Account Does not Exist on ${appName}\nRemoved from Local` : `Disconnected by Remote\n${appName}`, addresses);
34
+ };
35
+ /** The link may read "not connected" only because its first answer has not come back: wait for it a moment. */
36
+ function waitForLink() {
37
+ if (config.link().connected)
38
+ return Promise.resolve(true);
39
+ return new Promise((resolve) => {
40
+ let timer;
41
+ let off = () => undefined;
42
+ const done = (value) => {
43
+ off();
44
+ config.clearTimer(timer);
45
+ resolve(value);
46
+ };
47
+ off = config.subscribeLink(() => {
48
+ if (config.link().connected)
49
+ done(true);
50
+ });
51
+ timer = config.setTimer(() => done(false), LINK_WAIT_MS);
52
+ });
53
+ }
54
+ function withTimeout(promise, ms, what) {
55
+ return new Promise((resolve, reject) => {
56
+ const timer = config.setTimer(() => reject(new Error(`${what} timed out`)), ms);
57
+ promise.then((value) => {
58
+ config.clearTimer(timer);
59
+ resolve(value);
60
+ }, (error) => {
61
+ config.clearTimer(timer);
62
+ reject(error);
63
+ });
64
+ });
65
+ }
66
+ const readDisconnected = async () => (config.getDisconnected ? [...(await config.getDisconnected())] : []);
67
+ const writeDisconnected = async (accounts) => {
68
+ if (config.setDisconnected)
69
+ await config.setDisconnected(accounts);
70
+ };
71
+ /** Tell the other wallet which accounts are connected (merit_syncAccounts). Resolves with the list it really holds connected now. */
72
+ async function syncRemote(connected, why) {
73
+ const rid = config.newRid();
74
+ trace('request', { side, rid, method: 'merit_syncAccounts', asked: connected.length, why });
75
+ const reply = await config.request('merit_syncAccounts', [{ connected }], rid);
76
+ if (!reply.ok)
77
+ throw new MeritConnectError(reply.error.code, reply.error.message, reply.error.data);
78
+ return (Array.isArray(reply.result) ? reply.result : []).filter(isAddress);
79
+ }
80
+ /**
81
+ * Make the two lists one. The other wallet owns the accounts, so what it has connected decides the connected accounts here:
82
+ * - connected here but no longer connected there: the other wallet DISCONNECTED it, so it is REMOVED from this list;
83
+ * - connected there but not known here: the other wallet connected it, so it is ADDED as connected;
84
+ * - disconnected HERE (kept in the list) but still connected there: this wallet changed its state, so this wallet's choice wins and the other wallet is told.
85
+ */
86
+ async function handshake(options) {
87
+ const stored = [...(await config.getShared())];
88
+ const kept = await readDisconnected();
89
+ const listed = () => (config.getDisconnected ? { disconnected: kept } : {});
90
+ const unchanged = (skipped) => ({ changed: false, shared: stored, ...listed(), skipped });
91
+ if (changing) {
92
+ trace('handshake', { side, skipped: 'a change is being sent', stored: stored.length });
93
+ return unchanged('a change is being sent');
94
+ }
95
+ if (!(await waitForLink())) {
96
+ trace('handshake', { side, skipped: 'other wallet not connected', stored: stored.length });
97
+ return unchanged('other wallet not connected');
98
+ }
99
+ if (!options?.force && config.now() - lastHandshakeAt < HANDSHAKE_MIN_GAP_MS) {
100
+ trace('handshake', { side, skipped: 'asked a moment ago', stored: stored.length });
101
+ return unchanged('asked a moment ago');
102
+ }
103
+ lastHandshakeAt = config.now();
104
+ const rid = config.newRid();
105
+ try {
106
+ if (needsResync && stored.length) {
107
+ const there = await withTimeout(syncRemote(stored, 'reconnect'), HANDSHAKE_MS, 'handshake');
108
+ needsResync = false;
109
+ const missing = stored.filter((a) => !has(there, a));
110
+ trace('handshake', { side, rid, resync: true, sent: stored.length, connected: stored.length - missing.length, missing: missing.length });
111
+ if (!missing.length)
112
+ return { changed: false, shared: stored, ...listed() };
113
+ const next = without(stored, missing);
114
+ await writeDisconnected(without(kept, missing));
115
+ await config.setShared(next);
116
+ announce(missing, 'missing');
117
+ return { changed: true, shared: next, ...listed() };
118
+ }
119
+ needsResync = false;
120
+ const reply = await withTimeout(config.request('eth_accounts', undefined, rid), HANDSHAKE_MS, 'handshake');
121
+ if (!reply.ok)
122
+ throw new MeritConnectError(reply.error.code, reply.error.message);
123
+ const remote = (Array.isArray(reply.result) ? reply.result : []).filter(isAddress);
124
+ const removed = stored.filter((a) => !has(remote, a));
125
+ const added = remote.filter((a) => !has(stored, a) && !has(kept, a));
126
+ const mine = remote.filter((a) => has(kept, a));
127
+ const next = union(without(stored, removed), added);
128
+ const changed = !sameList(next, stored);
129
+ trace('handshake', { side, rid, stored: stored.length, shared: next.length, removed: removed.length, added: added.length, mineWins: mine.length, changed });
130
+ if (changed)
131
+ await config.setShared(next);
132
+ if (removed.length)
133
+ announce(removed, 'disconnected');
134
+ if (mine.length) {
135
+ try {
136
+ await syncRemote(next, 'a disconnect made here');
137
+ }
138
+ catch (error) {
139
+ trace('handshake', { side, rid, failed: error instanceof Error ? error.message : String(error), when: 'telling the other wallet about a disconnect' });
140
+ }
141
+ }
142
+ return { changed, shared: next, ...listed() };
143
+ }
144
+ catch (error) {
145
+ // a failed or timed-out handshake KEEPS the stored copy
146
+ trace('handshake', { side, rid, failed: error instanceof Error ? error.message : String(error), kept: stored.length });
147
+ return unchanged('failed');
148
+ }
149
+ }
150
+ async function fetchMeritConnections(options) {
151
+ const link = config.link();
152
+ const shared = options?.refresh === false || !link.connected ? [...(await config.getShared())] : (await handshake()).shared;
153
+ return { side, connected: link.connected, loggedOn: link.loggedOn, detached: link.detached, shared };
154
+ }
155
+ /** The accounts of the other wallet as this wallet lists them: the connected ones, then the ones kept but disconnected. */
156
+ async function listAccounts() {
157
+ const connected = [...(await config.getShared())];
158
+ const kept = without(await readDisconnected(), connected);
159
+ return [...connected.map((address) => ({ address, connected: true })), ...kept.map((address) => ({ address, connected: false }))];
160
+ }
161
+ /** Open the other wallet's account selector; resolves with the accounts it answers (the first change), which are kept. Later changes arrive as pushes through the relay. */
162
+ async function requestAccounts() {
163
+ const rid = config.newRid();
164
+ const held = [...(await config.getShared())];
165
+ trace('request', { side, rid, method: 'eth_requestAccounts', held: held.length });
166
+ const reply = await config.request('eth_requestAccounts', [{ shared: held }], rid);
167
+ if (!reply.ok) {
168
+ trace('failed', { side, rid, method: 'eth_requestAccounts', code: reply.error.code });
169
+ throw new MeritConnectError(reply.error.code, reply.error.message, reply.error.data);
170
+ }
171
+ const accounts = (Array.isArray(reply.result) ? reply.result : []).filter(isAddress);
172
+ // an account the other wallet now has connected is no longer one this wallet holds disconnected (written first: the list reload the shared list causes reads it)
173
+ await writeDisconnected(without(await readDisconnected(), accounts));
174
+ await config.setShared(accounts);
175
+ return accounts;
176
+ }
177
+ /** 2026-11-07 (owner: Connect has its own page, like MetaMask): connect THIS wallet to the other. The other wallet shows its own Connect page (Cancel / Connect); no account is chosen or changed here. Rejects when the person cancels there. */
178
+ async function requestConnection() {
179
+ const rid = config.newRid();
180
+ trace('request', { side, rid, method: 'merit_requestConnection' });
181
+ const reply = await config.request('merit_requestConnection', [], rid);
182
+ if (!reply.ok) {
183
+ trace('failed', { side, rid, method: 'merit_requestConnection', code: reply.error.code });
184
+ throw new MeritConnectError(reply.error.code, reply.error.message, reply.error.data);
185
+ }
186
+ }
187
+ /**
188
+ * Change one account's state from THIS wallet. Connect: the other wallet is told (it must be reachable) and answers with what really is connected. Disconnect: the account STAYS in this list as disconnected; the other wallet is told when it can
189
+ * be (when it cannot, the next handshake tells it: this wallet's choice wins).
190
+ */
191
+ async function setAccountConnected(address, connected) {
192
+ changing++;
193
+ try {
194
+ await changeAccount(address, connected);
195
+ }
196
+ finally {
197
+ changing--;
198
+ }
199
+ }
200
+ /**
201
+ * 2026-11-08 (owner, docs/design/removeAccountPlan.txt X3, X4): REMOVE one account of the other wallet from this wallet's list. The other wallet is told to disconnect it first (it must be reachable, else it would bring the account back at the next handshake),
202
+ * then the account is forgotten here: it is no longer shared, no longer kept disconnected, so it is not listed. It returns only when the other wallet connects it again. The other wallet keeps the account itself.
203
+ */
204
+ async function forgetAccount(address) {
205
+ if (!isAddress(address))
206
+ throw new MeritConnectError(ERROR.INVALID_PARAMS, 'That is not an account address.');
207
+ if (!(await waitForLink()))
208
+ throw new MeritConnectError(ERROR.UNAVAILABLE, 'The other wallet is not connected, so it cannot be told to disconnect this account. Connect it first, then remove the account.');
209
+ changing++;
210
+ try {
211
+ await changeAccount(address, false);
212
+ await writeDisconnected(without(await readDisconnected(), [address]));
213
+ }
214
+ finally {
215
+ changing--;
216
+ }
217
+ }
218
+ async function changeAccount(address, connected) {
219
+ if (!isAddress(address))
220
+ throw new MeritConnectError(ERROR.INVALID_PARAMS, 'That is not an account address.');
221
+ const current = [...(await config.getShared())];
222
+ const kept = await readDisconnected();
223
+ if (connected) {
224
+ if (!(await waitForLink()))
225
+ throw new MeritConnectError(ERROR.UNAVAILABLE, 'The other wallet is not connected, so the account cannot be connected there.');
226
+ const now = await syncRemote(union(current, [address]), 'connect ' + address.slice(0, 8));
227
+ if (!has(now, address))
228
+ throw new MeritConnectError(ERROR.UNAUTHORIZED, 'That account is not on the other wallet.');
229
+ await writeDisconnected(without(kept, now));
230
+ await config.setShared(now);
231
+ return;
232
+ }
233
+ const next = without(current, [address]);
234
+ await writeDisconnected(config.setDisconnected ? union(kept, [address]) : kept);
235
+ await config.setShared(next);
236
+ if (!config.link().connected) {
237
+ trace('request', { side, method: 'merit_syncAccounts', skipped: 'other wallet not connected: the next handshake tells it', disconnect: address.slice(0, 8) });
238
+ return;
239
+ }
240
+ try {
241
+ await syncRemote(next, 'disconnect ' + address.slice(0, 8));
242
+ }
243
+ catch (error) {
244
+ trace('request', { side, method: 'merit_syncAccounts', failed: error instanceof Error ? error.message : String(error), disconnect: address.slice(0, 8) });
245
+ }
246
+ }
247
+ /**
248
+ * Disconnect from the other wallet (2026-11-06, owner): this wallet's accounts KEEP their connected state (persisted, so the connection can be re-established); the OTHER wallet disconnects all of its accounts. When the link is made again
249
+ * the first handshake re-sends the connected list.
250
+ */
251
+ async function revoke() {
252
+ needsResync = true;
253
+ const rid = config.newRid();
254
+ try {
255
+ const reply = await config.request('wallet_revokePermissions', undefined, rid);
256
+ trace('revoke', { side, rid, ok: reply.ok, ...(reply.ok ? {} : { code: reply.error.code }) });
257
+ }
258
+ catch (error) {
259
+ trace('revoke', { side, rid, failed: error instanceof Error ? error.message : String(error) });
260
+ }
261
+ }
262
+ return { handshake, fetchMeritConnections, requestAccounts, requestConnection, revoke, setAccountConnected, forgetAccount, listAccounts };
263
+ }
264
+ /** The standard calls, for the sides this host can reach. A side that was not configured throws 4900. */
265
+ export function createMeritConnect(sides) {
266
+ const clients = new Map();
267
+ const client = (side) => {
268
+ const config = sides[side];
269
+ if (!config)
270
+ throw new MeritConnectError(ERROR.UNAVAILABLE, `This host has no connection to ${side}.`);
271
+ let made = clients.get(side);
272
+ if (!made) {
273
+ made = createSideClient(side, config);
274
+ clients.set(side, made);
275
+ }
276
+ return made;
277
+ };
278
+ return {
279
+ fetchMeritConnections: async (side, options) => client(side).fetchMeritConnections(options),
280
+ requestAccounts: async (side) => client(side).requestAccounts(),
281
+ /** Connect this wallet to the other: its own Connect page (Cancel / Connect), no accounts. */
282
+ requestConnection: async (side) => client(side).requestConnection(),
283
+ handshake: async (side, options) => client(side).handshake(options),
284
+ revoke: async (side) => client(side).revoke(),
285
+ /** Connect or disconnect ONE account from this wallet (a disconnected account stays in the list). */
286
+ setAccountConnected: async (side, address, connected) => client(side).setAccountConnected(address, connected),
287
+ /** Remove ONE account of the other wallet from this wallet's list (the other wallet is told to disconnect it first and keeps the account). */
288
+ forgetAccount: async (side, address) => client(side).forgetAccount(address),
289
+ /** The accounts as this wallet lists them, each connected or disconnected. */
290
+ listAccounts: async (side) => client(side).listAccounts(),
291
+ /** The link to that side came up or went down: the host re-runs its list load (which runs the handshake). */
292
+ onConnectionChange: (side, listener) => {
293
+ const config = sides[side];
294
+ if (!config)
295
+ throw new MeritConnectError(ERROR.UNAVAILABLE, `This host has no connection to ${side}.`);
296
+ return config.subscribeLink(listener);
297
+ },
298
+ };
299
+ }
@@ -0,0 +1,47 @@
1
+ import { type TraceFn } from './trace.js';
2
+ export type ConnectionState = 'connected' | 'detached' | 'page-silent' | 'no-page' | 'unknown';
3
+ export interface ProbeResult {
4
+ /** Pages of the other wallet that answered. */
5
+ reporting: number;
6
+ /** Open pages that could be the other wallet but did NOT answer. */
7
+ candidates: number;
8
+ /** The answering page's wallet is logged on. */
9
+ loggedOn: boolean;
10
+ }
11
+ export interface ConnectionStatus {
12
+ state: ConnectionState;
13
+ /** state === 'connected' */
14
+ connected: boolean;
15
+ /** A sentence for the person: what is wrong and what to do (or that all is well). */
16
+ reason: string;
17
+ loggedOn: boolean;
18
+ reporting: number;
19
+ candidates: number;
20
+ detached: boolean;
21
+ /** When this status was worked out. */
22
+ at: number;
23
+ }
24
+ export interface ConnectionMonitorDeps {
25
+ /** One round trip. undefined = it could not be asked. */
26
+ probe(): Promise<ProbeResult | undefined>;
27
+ /** The person's own choice to disconnect (kept by the host). */
28
+ isDetached(): boolean;
29
+ setTimer(fn: () => void, ms: number): unknown;
30
+ clearTimer(handle: unknown): void;
31
+ now(): number;
32
+ /** How the other wallet is called in the sentences (default "Merit web app"). */
33
+ label?: string;
34
+ trace?: TraceFn;
35
+ }
36
+ export declare const CONNECT_WAIT_MS = 15000;
37
+ export declare const CONNECT_POLL_MS = 2000;
38
+ export declare function describeConnection(state: ConnectionState, label: string): string;
39
+ export declare function createConnectionMonitor(deps: ConnectionMonitorDeps): {
40
+ getSnapshot: () => ConnectionStatus;
41
+ subscribe: (listener: () => void) => () => void;
42
+ refresh: () => Promise<ConnectionStatus>;
43
+ /** The host's own state changed (the person disconnected or connected): re-derive without asking. */
44
+ recompute: () => ConnectionStatus;
45
+ waitForConnected: (timeoutMs?: number, pollMs?: number) => Promise<ConnectionStatus>;
46
+ };
47
+ export type ConnectionMonitor = ReturnType<typeof createConnectionMonitor>;