Merge branch 'nicer-react' into 'master'
Add session persistence and refactor dapp manager See merge request riftenlabs/lib/wizardconnect!19
This commit is contained in:
commit
4bcbef2aae
12 changed files with 732 additions and 305 deletions
219
docs/dapp.md
219
docs/dapp.md
|
|
@ -17,24 +17,34 @@ class DappConnectionManager extends EventEmitter {
|
|||
/** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
|
||||
protocol: string | null;
|
||||
|
||||
constructor(dappName?: string, dappIcon?: string)
|
||||
constructor(dappName?: string, dappIcon?: string, options?: {
|
||||
/** Session persistence config. Enabled by default (key: "wizardconnect-session",
|
||||
* storage: localStorage). Pass `false` to disable. */
|
||||
session?: DappSessionOptions | false;
|
||||
})
|
||||
|
||||
/** Call from the RelayStatusCallback each time the relay status changes.
|
||||
* Attaches the message listener exactly once (on first client seen).
|
||||
* Triggers onConnected() on each "connected" event. */
|
||||
/** Call from the RelayStatusCallback each time the relay status changes. */
|
||||
updateConnection(client: RelayClient | null, status: RelayStatus): void
|
||||
|
||||
isWalletDiscovered(): boolean
|
||||
|
||||
/** Get the next sequence number for a SignTransactionRequest. */
|
||||
nextSequence(): number
|
||||
/** Convenience: build a full SignTransactionRequest, send it, and optionally
|
||||
* cancel via AbortSignal. See "Sending a sign request" below. */
|
||||
signTransaction(
|
||||
request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<SignTransactionResponse>
|
||||
|
||||
/** Send a sign request and wait for the wallet's response.
|
||||
* Rejects if the wallet returns an error or if the connection drops. */
|
||||
/** Low-level: send a fully constructed sign request. */
|
||||
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
|
||||
|
||||
/** Send a UserDisconnect courtesy message to the wallet.
|
||||
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
|
||||
/** Cancel an in-flight sign request by sequence number. */
|
||||
sendSignCancel(sequence: number, reason?: string): Promise<void>
|
||||
|
||||
/** Get the next sequence number (for manual request construction). */
|
||||
nextSequence(): number
|
||||
|
||||
/** Send a UserDisconnect courtesy message to the wallet. */
|
||||
sendDisconnect(message?: string): Promise<void>
|
||||
|
||||
/** Get raw PathXpub[] received in wallet_ready (for caching). */
|
||||
|
|
@ -43,6 +53,11 @@ class DappConnectionManager extends EventEmitter {
|
|||
/** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
|
||||
restoreSessionPaths(paths: PathXpub[]): void
|
||||
|
||||
// Session persistence (see "Session persistence" section below)
|
||||
attachRelay(relay: DappRelayResult): void
|
||||
loadStoredSession(): StoredSession | null
|
||||
clearStoredSession(): void
|
||||
|
||||
// Events
|
||||
on("walletready", (msg: WalletReadyMessage) => void)
|
||||
on("messagesent", (msg: ProtocolMessage) => void)
|
||||
|
|
@ -107,11 +122,12 @@ const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.p
|
|||
const relay = initiateDappRelay(
|
||||
(payload) => {
|
||||
dappMgr.updateConnection(payload.client, payload.status);
|
||||
// also update your own UI state here (connected/disconnected indicator)
|
||||
},
|
||||
{ explicitRelayUrls: ["wss://relay.cauldron.quest:443"] },
|
||||
);
|
||||
|
||||
// Persist relay credentials and auto-save walletPublicKey on key exchange
|
||||
dappMgr.attachRelay(relay);
|
||||
|
||||
// Show relay.uri as a QR code for the wallet to scan.
|
||||
console.log("Scan this URI:", relay.uri);
|
||||
```
|
||||
|
|
@ -138,37 +154,66 @@ See [pubkey-derivation.md](pubkey-derivation.md) for full details.
|
|||
|
||||
### Sending a sign request
|
||||
|
||||
The `signTransaction` convenience method auto-fills `action`, `sequence`, and `time`:
|
||||
|
||||
```typescript
|
||||
const response = await dappMgr.signTransaction({
|
||||
transaction: {
|
||||
transaction: txHex,
|
||||
sourceOutputs,
|
||||
userPrompt: "Confirm swap",
|
||||
broadcast: true,
|
||||
},
|
||||
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
|
||||
});
|
||||
|
||||
console.log("Signed tx:", response.signedTransaction);
|
||||
```
|
||||
|
||||
#### Cancellation via AbortSignal
|
||||
|
||||
Pass an `AbortSignal` to automatically cancel the request when aborted. This sends
|
||||
`sign_cancel` to the wallet and rejects the promise with an `AbortError`:
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
cancelButton.onclick = () => controller.abort("User cancelled");
|
||||
|
||||
try {
|
||||
const response = await dappMgr.signTransaction(
|
||||
{ transaction: { ... }, inputPaths: [...] },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
console.log("User cancelled the signature request");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Low-level: sendSignRequest
|
||||
|
||||
For full control over the request, use `sendSignRequest` directly:
|
||||
|
||||
```typescript
|
||||
const seq = dappMgr.nextSequence();
|
||||
const request: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: seq,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
transaction: {
|
||||
transaction: { inputs, outputs, version: 2, locktime: 0 },
|
||||
sourceOutputs,
|
||||
userPrompt: "Confirm swap",
|
||||
broadcast: true,
|
||||
},
|
||||
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
|
||||
transaction: { ... },
|
||||
inputPaths: [[0, "receive", 0]],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await dappMgr.sendSignRequest(request);
|
||||
console.log("Signed tx:", response.signedTransaction);
|
||||
} catch (err) {
|
||||
console.error("Signing failed:", err.message);
|
||||
}
|
||||
const response = await dappMgr.sendSignRequest(request);
|
||||
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");
|
||||
```
|
||||
|
||||
`sendSignRequest` returns a Promise that resolves when the wallet sends back a
|
||||
`sign_transaction_response` with the matching `sequence`. It rejects if the wallet sends an
|
||||
error response.
|
||||
|
||||
### Disconnecting
|
||||
|
||||
```typescript
|
||||
// Dapp-initiated: send courtesy message, then tear down the relay
|
||||
// Dapp-initiated: send courtesy message, clear session, then tear down the relay
|
||||
dappMgr.clearStoredSession();
|
||||
await dappMgr.sendDisconnect("user closed the tab");
|
||||
relay.cleanup();
|
||||
|
||||
|
|
@ -189,27 +234,85 @@ The `disconnect` event fires in two cases:
|
|||
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
|
||||
wallet before emitting the event.
|
||||
|
||||
### Session path persistence
|
||||
### Session persistence
|
||||
|
||||
After `wallet_ready`, the manager stores the raw `PathXpub[]` from the wallet. Use
|
||||
`getSessionPaths()` to retrieve them (e.g. for caching in localStorage). On a subsequent
|
||||
page load, restore them with `restoreSessionPaths()` so `getPubkey()` works immediately
|
||||
without waiting for the wallet to reconnect:
|
||||
Session persistence is **enabled by default**. The manager automatically:
|
||||
|
||||
- **On construction**: restores xpub paths from storage so `getPubkey()` works immediately.
|
||||
- **On `walletready`**: saves `walletName`, `walletIcon`, and xpub `paths` to storage.
|
||||
|
||||
The default storage key is `"wizardconnect-session"` and the default backend is `localStorage`.
|
||||
|
||||
#### Saving session data
|
||||
|
||||
Call `attachRelay()` after `initiateDappRelay()` to persist relay credentials and
|
||||
automatically save the wallet public key when key exchange completes:
|
||||
|
||||
```typescript
|
||||
// After wallet_ready — save for later
|
||||
const paths = dappMgr.getSessionPaths();
|
||||
localStorage.setItem("myapp-paths", JSON.stringify(paths));
|
||||
const relay = initiateDappRelay(callback);
|
||||
dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey
|
||||
|
||||
// On page load — restore before wallet reconnects
|
||||
const cached = JSON.parse(localStorage.getItem("myapp-paths") ?? "null");
|
||||
if (cached) {
|
||||
dappMgr.restoreSessionPaths(cached);
|
||||
// getPubkey() now works without wallet_ready
|
||||
// On disconnect:
|
||||
dappMgr.clearStoredSession();
|
||||
```
|
||||
|
||||
#### Loading for reconnection
|
||||
|
||||
Use `loadStoredSession()` on an existing manager, or the standalone `loadSession()` when
|
||||
you need to read the session before creating the manager (e.g. to get relay credentials
|
||||
for `initiateDappRelay`):
|
||||
|
||||
```typescript
|
||||
import { loadSession } from "@wizardconnect/dapp";
|
||||
|
||||
const session = loadSession(); // uses default key and localStorage
|
||||
if (session?.walletPublicKey) {
|
||||
const relay = initiateDappRelay(callback, { existingCredentials: session });
|
||||
}
|
||||
```
|
||||
|
||||
Throws if any xpub string is invalid (corrupt cached data should be cleared).
|
||||
#### Configuration
|
||||
|
||||
```typescript
|
||||
// Default: session enabled, key "wizardconnect-session", localStorage
|
||||
const mgr = new DappConnectionManager("My Dapp");
|
||||
|
||||
// Custom key:
|
||||
const mgr = new DappConnectionManager("My Dapp", undefined, {
|
||||
session: { key: "my-app-session" },
|
||||
});
|
||||
|
||||
// Custom storage backend (e.g. for React Native or SSR):
|
||||
const mgr = new DappConnectionManager("My Dapp", undefined, {
|
||||
session: { storage: myCustomStorage },
|
||||
});
|
||||
|
||||
// Disable session persistence:
|
||||
const mgr = new DappConnectionManager("My Dapp", undefined, {
|
||||
session: false,
|
||||
});
|
||||
```
|
||||
|
||||
The `SessionStorage` interface matches the Web Storage API:
|
||||
|
||||
```typescript
|
||||
interface SessionStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Manual path management
|
||||
|
||||
For advanced use cases, `getSessionPaths()` and `restoreSessionPaths()` are still available:
|
||||
|
||||
```typescript
|
||||
const paths = dappMgr.getSessionPaths(); // raw PathXpub[] from wallet_ready
|
||||
dappMgr.restoreSessionPaths(paths); // re-populate pubkeyState from cached paths
|
||||
```
|
||||
|
||||
`restoreSessionPaths` throws if any xpub string is invalid.
|
||||
|
||||
### Reconnection
|
||||
|
||||
|
|
@ -247,14 +350,24 @@ relay.events.on("keyexchangecomplete", async (walletPubkey) => {
|
|||
});
|
||||
```
|
||||
|
||||
## Cauldron (cauldron-beta) implementation notes
|
||||
## Using with React
|
||||
|
||||
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
|
||||
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
|
||||
For React dapps, prefer the `useWizardConnect` hook from `@wizardconnect/react` over
|
||||
managing the relay lifecycle manually. The hook handles session persistence, auto-reconnect,
|
||||
and relay cleanup automatically. See [react.md](react.md).
|
||||
|
||||
Key design points:
|
||||
- `DappConnectionManager` is created per connection session (not a singleton).
|
||||
- `updateConnection()` is called from the relay status callback.
|
||||
- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns.
|
||||
- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName
|
||||
when processing `wallet_ready` data.
|
||||
Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap
|
||||
the returned `manager` in their adapter:
|
||||
|
||||
```typescript
|
||||
const wc = useWizardConnect({ dappName: "My Dapp" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!wc.manager) return;
|
||||
const wallet = new MyWalletAdapter(wc.manager);
|
||||
// dispatch wallet to your store
|
||||
}, [wc.manager]);
|
||||
```
|
||||
|
||||
The `DappConnectionManager` is created by the hook; the adapter receives it rather than
|
||||
creating its own.
|
||||
|
|
|
|||
|
|
@ -38,14 +38,16 @@ function App() {
|
|||
|
||||
The `useWizardConnect` hook manages the full relay lifecycle:
|
||||
|
||||
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, and returns
|
||||
the connection URI for QR display.
|
||||
2. **Key exchange** — listens for `keyexchangecomplete` and persists the wallet's public key to
|
||||
localStorage for auto-reconnect.
|
||||
3. **`walletready`** — updates `state` to `"connected"` and populates `walletName`/`walletIcon`.
|
||||
4. **Auto-reconnect** — on mount, checks localStorage for a stored session with a `walletPublicKey`
|
||||
and automatically reconnects.
|
||||
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, sets
|
||||
`uri`/`qrUri` for QR display, and transitions to `"connecting"`.
|
||||
2. **Key exchange** — persists the wallet's public key to session storage.
|
||||
3. **`walletready`** — transitions to `"connected"` and populates `walletName`/`walletIcon`.
|
||||
4. **Auto-reconnect** — on mount, checks session storage for a stored session with a
|
||||
`walletPublicKey` and automatically reconnects. During auto-reconnect, `uri`/`qrUri` remain
|
||||
`null` (no QR code to display — credentials are already known).
|
||||
5. **`disconnect()`** — sends a courtesy disconnect, cleans up the relay, and clears session storage.
|
||||
6. **Remote disconnect** — when the wallet sends a disconnect, the hook transitions to
|
||||
`"disconnected"`, clears the session, and tears down the relay.
|
||||
|
||||
### Return value
|
||||
|
||||
|
|
@ -72,11 +74,16 @@ Once `state === "connected"`, the `manager` is a live `DappConnectionManager` fr
|
|||
// Derive a receive address pubkey
|
||||
const pubkey = wc.manager.getPubkey(0, 0n);
|
||||
|
||||
// Send a sign request
|
||||
const seq = wc.manager.nextSequence();
|
||||
const response = await wc.manager.sendSignRequest({ ... });
|
||||
// Send a sign request (auto-fills action, sequence, time)
|
||||
const response = await wc.manager.signTransaction({
|
||||
transaction: { transaction: txHex, sourceOutputs, broadcast: true },
|
||||
inputPaths: [[0, "receive", 0]],
|
||||
});
|
||||
```
|
||||
|
||||
See the [dapp integration docs](dapp.md#sending-a-sign-request) for cancellation via
|
||||
`AbortSignal` and the low-level `sendSignRequest` API.
|
||||
|
||||
For most dapps, you'll wrap the manager in an app-specific wallet adapter (like
|
||||
`RelayWalletDapp` in the Cauldron and Moria codebases).
|
||||
|
||||
|
|
@ -130,21 +137,48 @@ level H (30% recovery), which allows a center logo overlay without breaking the
|
|||
|
||||
## Session persistence
|
||||
|
||||
By default, `useWizardConnect` persists session credentials (private key, shared secret,
|
||||
wallet public key, and xpub paths) to localStorage under the `wizardconnect-session` key.
|
||||
This enables auto-reconnect when the user refreshes the page.
|
||||
By default, `useWizardConnect` persists session data to localStorage under the
|
||||
`wizardconnect-session` key. The stored session includes:
|
||||
|
||||
When xpub paths are cached, they are restored via `restoreSessionPaths()` on auto-reconnect
|
||||
so that `getPubkey()` works immediately — before the wallet sends a new `wallet_ready`.
|
||||
- **Relay credentials** (`privateKey`, `secret`) — saved on `connect()`
|
||||
- **Wallet public key** (`walletPublicKey`) — saved after key exchange
|
||||
- **Wallet identity** (`walletName`, `walletIcon`) — saved on `walletready`
|
||||
- **Xpub paths** (`paths`) — saved on `walletready`
|
||||
|
||||
The persistence key and behavior can be customized:
|
||||
On page refresh, the hook auto-reconnects if a stored session with `walletPublicKey` exists.
|
||||
Xpub paths and wallet name are restored immediately so `getPubkey()` works and the UI can
|
||||
display the wallet name before the wallet app responds. During auto-reconnect, `uri` and
|
||||
`qrUri` remain `null` — the QR dialog should not be shown. The typical pattern is:
|
||||
|
||||
```tsx
|
||||
{wc.uri && wc.qrUri && (
|
||||
<WizardConnectQRDialog
|
||||
show={wc.state === "connecting"}
|
||||
onClose={() => wc.disconnect()}
|
||||
uri={wc.uri}
|
||||
qrUri={wc.qrUri}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
This naturally hides the dialog during auto-reconnect since `wc.uri` is `null`.
|
||||
|
||||
If the wallet sends a disconnect, the hook clears the session automatically and transitions
|
||||
to `"disconnected"`. Dapps should listen for this state change to update their UI (e.g.
|
||||
clear the wallet from their store).
|
||||
|
||||
### Customization
|
||||
|
||||
```typescript
|
||||
useWizardConnect({
|
||||
sessionKey: "my-app-wc-session", // custom localStorage key
|
||||
persistSession: false, // disable persistence entirely
|
||||
sessionKey: "my-app-session", // custom storage key (default: "wizardconnect-session")
|
||||
persistSession: false, // disable persistence entirely
|
||||
storage: myCustomStorage, // custom SessionStorage backend (default: localStorage)
|
||||
});
|
||||
```
|
||||
|
||||
Credentials are only stored after the key exchange completes. On disconnect, the stored session
|
||||
is cleared.
|
||||
The `storage` option accepts any object with `getItem`, `setItem`, and `removeItem` methods
|
||||
(the standard Web Storage API). This is useful for React Native or server-side rendering
|
||||
where `localStorage` is not available.
|
||||
|
||||
On disconnect, the stored session is cleared.
|
||||
|
|
|
|||
143
package-lock.json
generated
143
package-lock.json
generated
|
|
@ -1147,34 +1147,6 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
|
|
@ -2435,26 +2407,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify/node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lossless-json": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
|
||||
|
|
@ -2820,33 +2772,6 @@
|
|||
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
|
|
@ -2918,16 +2843,6 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -3522,11 +3437,11 @@
|
|||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"happy-dom": "^20.8.4",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
|
|
@ -3535,6 +3450,56 @@
|
|||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/react/node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"packages/react/node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"packages/react/node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"packages/react/node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"packages/react/node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/test-cli": {
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.1.0",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,25 @@ import {
|
|||
PROTOCOL_NAME,
|
||||
childIndexOfPathName,
|
||||
isHdwalletv1Session,
|
||||
binToHex,
|
||||
} from "@wizardconnect/core";
|
||||
import type { PathXpub } from "@wizardconnect/core";
|
||||
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
|
||||
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
import {
|
||||
type SessionStorage,
|
||||
type StoredSession,
|
||||
DEFAULT_SESSION_KEY,
|
||||
loadSession,
|
||||
saveSession,
|
||||
clearSession,
|
||||
} from "./session.js";
|
||||
|
||||
export interface DappSessionOptions {
|
||||
/** Storage key for session persistence. Default: "wizardconnect-session" */
|
||||
key?: string;
|
||||
/** Storage backend. Defaults to localStorage if available. */
|
||||
storage?: SessionStorage;
|
||||
}
|
||||
|
||||
export interface DappConnectionManagerEvents {
|
||||
/** Fired after wallet_ready is received and state is updated. */
|
||||
|
|
@ -70,19 +86,98 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
reject: (e: Error) => void;
|
||||
}
|
||||
>();
|
||||
private sessionOptions: { key: string; storage?: SessionStorage } | null =
|
||||
null;
|
||||
|
||||
/**
|
||||
* @param dappName Optional display name of the dapp (sent in dapp_ready).
|
||||
* @param dappIcon Optional icon URL/data-URI of the dapp (sent in dapp_ready).
|
||||
* @param options Optional configuration. Session persistence is enabled by
|
||||
* default (key: "wizardconnect-session", storage: localStorage).
|
||||
* Pass `session: false` to disable.
|
||||
*/
|
||||
constructor(
|
||||
private dappName?: string,
|
||||
private dappIcon?: string,
|
||||
options?: { session?: DappSessionOptions | false },
|
||||
) {
|
||||
super();
|
||||
this.pubkeyState = new DappPubkeyStateManager();
|
||||
|
||||
if (options?.session !== false) {
|
||||
const sessionConf = options?.session ?? {};
|
||||
this.sessionOptions = {
|
||||
key: sessionConf.key ?? DEFAULT_SESSION_KEY,
|
||||
storage: sessionConf.storage,
|
||||
};
|
||||
// Auto-restore from stored session
|
||||
const stored = loadSession(
|
||||
this.sessionOptions.key,
|
||||
this.sessionOptions.storage,
|
||||
);
|
||||
if (stored) {
|
||||
if (stored.walletName) this.walletName = stored.walletName;
|
||||
if (stored.walletIcon) this.walletIcon = stored.walletIcon;
|
||||
if (stored.paths?.length) {
|
||||
try {
|
||||
this.restoreSessionPaths(stored.paths);
|
||||
} catch {
|
||||
// Corrupt cached paths — ignore, wallet_ready will repopulate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session persistence (public API) ----------------------------------------
|
||||
|
||||
/**
|
||||
* Attach a relay result from `initiateDappRelay()`. Automatically:
|
||||
* - Saves relay credentials (privateKey, secret) to the session
|
||||
* - Listens for `keyexchangecomplete` and saves the wallet public key
|
||||
*
|
||||
* No-op if session persistence is disabled.
|
||||
*/
|
||||
attachRelay(relay: DappRelayResult): void {
|
||||
if (!this.sessionOptions) return;
|
||||
saveSession(
|
||||
this.sessionOptions.key,
|
||||
{
|
||||
privateKey: relay.credentials.privateKey,
|
||||
secret: relay.credentials.secret,
|
||||
},
|
||||
this.sessionOptions.storage,
|
||||
);
|
||||
relay.events.on("keyexchangecomplete", (walletPublicKey: Uint8Array) => {
|
||||
if (!this.sessionOptions) return;
|
||||
saveSession(
|
||||
this.sessionOptions.key,
|
||||
{ walletPublicKey: binToHex(walletPublicKey) },
|
||||
this.sessionOptions.storage,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the stored session (e.g. for reconnection).
|
||||
* Returns null if session persistence is disabled or no session exists.
|
||||
*/
|
||||
loadStoredSession(): StoredSession | null {
|
||||
if (!this.sessionOptions) return null;
|
||||
return loadSession(this.sessionOptions.key, this.sessionOptions.storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stored session. Call on disconnect.
|
||||
* No-op if session persistence is disabled.
|
||||
*/
|
||||
clearStoredSession(): void {
|
||||
if (!this.sessionOptions) return;
|
||||
clearSession(this.sessionOptions.key, this.sessionOptions.storage);
|
||||
}
|
||||
|
||||
// --- Relay connection -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Call this from the RelayStatusCallback passed to `initiateDappRelay`.
|
||||
* Attaches the message listener exactly once and re-sends dapp_ready
|
||||
|
|
@ -185,6 +280,57 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
this.emit("messagesent", msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: build and send a sign transaction request.
|
||||
* Auto-fills `action`, `sequence`, and `time`. Supports cancellation via
|
||||
* AbortSignal — when aborted, sendSignCancel is called automatically.
|
||||
*/
|
||||
async signTransaction(
|
||||
request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<SignTransactionResponse> {
|
||||
const sequence = this.nextSequence();
|
||||
const fullRequest: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
sequence,
|
||||
...request,
|
||||
};
|
||||
|
||||
const signPromise = this.sendSignRequest(fullRequest);
|
||||
|
||||
if (!options?.signal) return signPromise;
|
||||
|
||||
// Suppress unhandled rejection — abort path rejects separately
|
||||
signPromise.catch(() => {});
|
||||
|
||||
return new Promise<SignTransactionResponse>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
const reason =
|
||||
options.signal!.reason instanceof Error
|
||||
? options.signal!.reason.message
|
||||
: typeof options.signal!.reason === "string"
|
||||
? options.signal!.reason
|
||||
: "Sign request cancelled";
|
||||
this.sendSignCancel(sequence, reason).catch(() => {});
|
||||
reject(new DOMException(reason, "AbortError"));
|
||||
};
|
||||
|
||||
if (options.signal!.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
options.signal!.addEventListener("abort", onAbort, { once: true });
|
||||
signPromise
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => {
|
||||
options.signal!.removeEventListener("abort", onAbort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Pubkey state delegation ------------------------------------------------
|
||||
// Convenience methods that forward to pubkeyState.
|
||||
|
||||
|
|
@ -352,6 +498,20 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
|
||||
this.emit("walletready", msg);
|
||||
|
||||
// Auto-persist wallet identity and xpub paths to session storage
|
||||
if (this.sessionOptions) {
|
||||
const sessionUpdate: Partial<StoredSession> = {
|
||||
walletName: this.walletName ?? undefined,
|
||||
walletIcon: this.walletIcon ?? undefined,
|
||||
paths: this.getSessionPaths(),
|
||||
};
|
||||
saveSession(
|
||||
this.sessionOptions.key,
|
||||
sessionUpdate,
|
||||
this.sessionOptions.storage,
|
||||
);
|
||||
}
|
||||
|
||||
// Re-send any pending sign requests the wallet may have missed
|
||||
// (e.g. wallet app wasn't open when the request was first sent).
|
||||
if (this.pendingSignatureRequests.size > 0 && this.conn) {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,15 @@
|
|||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
export { DappConnectionManager } from "./dapp-connection-manager.js";
|
||||
export type { DappConnectionManagerEvents } from "./dapp-connection-manager.js";
|
||||
export type {
|
||||
DappConnectionManagerEvents,
|
||||
DappSessionOptions,
|
||||
} from "./dapp-connection-manager.js";
|
||||
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
export {
|
||||
DEFAULT_SESSION_KEY,
|
||||
loadSession,
|
||||
saveSession,
|
||||
clearSession,
|
||||
} from "./session.js";
|
||||
export type { SessionStorage, StoredSession } from "./session.js";
|
||||
|
|
|
|||
112
packages/dapp/src/session.test.ts
Normal file
112
packages/dapp/src/session.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
loadSession,
|
||||
saveSession,
|
||||
clearSession,
|
||||
type SessionStorage,
|
||||
type StoredSession,
|
||||
} from "./session.js";
|
||||
|
||||
/** In-memory storage for testing (avoids depending on a DOM environment). */
|
||||
function createMemoryStorage(): SessionStorage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => store.set(key, value),
|
||||
removeItem: (key) => store.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
describe("session utilities", () => {
|
||||
let storage: SessionStorage;
|
||||
const KEY = "test-session";
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMemoryStorage();
|
||||
});
|
||||
|
||||
describe("loadSession", () => {
|
||||
it("returns null for missing key", () => {
|
||||
expect(loadSession(KEY, storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
storage.setItem(KEY, "not-json{");
|
||||
expect(loadSession(KEY, storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when privateKey is missing", () => {
|
||||
storage.setItem(KEY, JSON.stringify({ secret: "s" }));
|
||||
expect(loadSession(KEY, storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when secret is missing", () => {
|
||||
storage.setItem(KEY, JSON.stringify({ privateKey: "pk" }));
|
||||
expect(loadSession(KEY, storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns session with required fields", () => {
|
||||
const session: StoredSession = { privateKey: "pk", secret: "s" };
|
||||
storage.setItem(KEY, JSON.stringify(session));
|
||||
expect(loadSession(KEY, storage)).toEqual(session);
|
||||
});
|
||||
|
||||
it("returns session with all optional fields", () => {
|
||||
const session: StoredSession = {
|
||||
privateKey: "pk",
|
||||
secret: "s",
|
||||
walletPublicKey: "wpk",
|
||||
walletName: "TestWallet",
|
||||
walletIcon: "icon.png",
|
||||
paths: [{ name: "receive", xpub: "xpub123" }],
|
||||
};
|
||||
storage.setItem(KEY, JSON.stringify(session));
|
||||
expect(loadSession(KEY, storage)).toEqual(session);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSession", () => {
|
||||
it("saves a new session", () => {
|
||||
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
|
||||
const loaded = loadSession(KEY, storage);
|
||||
expect(loaded).toEqual({ privateKey: "pk", secret: "s" });
|
||||
});
|
||||
|
||||
it("merges with existing session", () => {
|
||||
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
|
||||
saveSession(KEY, { walletPublicKey: "wpk" }, storage);
|
||||
const loaded = loadSession(KEY, storage);
|
||||
expect(loaded).toEqual({
|
||||
privateKey: "pk",
|
||||
secret: "s",
|
||||
walletPublicKey: "wpk",
|
||||
});
|
||||
});
|
||||
|
||||
it("overwrites fields on merge", () => {
|
||||
saveSession(
|
||||
KEY,
|
||||
{ privateKey: "pk", secret: "s", walletName: "Old" },
|
||||
storage,
|
||||
);
|
||||
saveSession(KEY, { walletName: "New" }, storage);
|
||||
expect(loadSession(KEY, storage)?.walletName).toBe("New");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearSession", () => {
|
||||
it("removes the stored session", () => {
|
||||
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
|
||||
clearSession(KEY, storage);
|
||||
expect(loadSession(KEY, storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op for missing key", () => {
|
||||
expect(() => clearSession(KEY, storage)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
87
packages/dapp/src/session.ts
Normal file
87
packages/dapp/src/session.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import type { PathXpub } from "@wizardconnect/core";
|
||||
|
||||
/**
|
||||
* Abstraction over localStorage for session persistence.
|
||||
* Matches the Web Storage API subset, so `localStorage` can be passed directly.
|
||||
*/
|
||||
export interface SessionStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/** Persisted session data for a WizardConnect dapp connection. */
|
||||
export interface StoredSession {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
walletPublicKey?: string;
|
||||
walletName?: string;
|
||||
walletIcon?: string;
|
||||
paths?: PathXpub[];
|
||||
}
|
||||
|
||||
function defaultStorage(): SessionStorage | null {
|
||||
if (
|
||||
typeof localStorage !== "undefined" &&
|
||||
typeof localStorage.getItem === "function"
|
||||
)
|
||||
return localStorage;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStorage(storage?: SessionStorage): SessionStorage | null {
|
||||
return storage ?? defaultStorage();
|
||||
}
|
||||
|
||||
export const DEFAULT_SESSION_KEY = "wizardconnect-session";
|
||||
|
||||
/**
|
||||
* Load a stored session. Returns null if the key is missing, the data is
|
||||
* malformed, or the required fields (privateKey, secret) are absent.
|
||||
*/
|
||||
export function loadSession(
|
||||
key: string = DEFAULT_SESSION_KEY,
|
||||
storage?: SessionStorage,
|
||||
): StoredSession | null {
|
||||
const s = resolveStorage(storage);
|
||||
if (!s) return null;
|
||||
const raw = s.getItem(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
if (!parsed.privateKey || !parsed.secret) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session data. Merges with any existing stored session so callers
|
||||
* can save incrementally (e.g. credentials first, then walletPublicKey later).
|
||||
*/
|
||||
export function saveSession(
|
||||
key: string = DEFAULT_SESSION_KEY,
|
||||
data: Partial<StoredSession>,
|
||||
storage?: SessionStorage,
|
||||
): void {
|
||||
const s = resolveStorage(storage);
|
||||
if (!s) return;
|
||||
const existing = loadSession(key, s);
|
||||
const merged = { ...existing, ...data };
|
||||
s.setItem(key, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
/** Remove a stored session. */
|
||||
export function clearSession(
|
||||
key: string = DEFAULT_SESSION_KEY,
|
||||
storage?: SessionStorage,
|
||||
): void {
|
||||
const s = resolveStorage(storage);
|
||||
if (!s) return;
|
||||
s.removeItem(key);
|
||||
}
|
||||
|
|
@ -33,11 +33,11 @@
|
|||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"happy-dom": "^20.8.4",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,15 @@ vi.mock("@wizardconnect/core", () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("@wizardconnect/dapp", () => {
|
||||
vi.mock("@wizardconnect/dapp", async () => {
|
||||
const actual = await vi.importActual<typeof import("@wizardconnect/dapp")>(
|
||||
"@wizardconnect/dapp",
|
||||
);
|
||||
return {
|
||||
// Use real session utilities (they work with our mock localStorage)
|
||||
loadSession: actual.loadSession,
|
||||
saveSession: actual.saveSession,
|
||||
clearSession: actual.clearSession,
|
||||
DappConnectionManager: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
|
|
@ -53,6 +60,9 @@ vi.mock("@wizardconnect/dapp", () => {
|
|||
sendDisconnect: vi.fn(() => Promise.resolve()),
|
||||
getSessionPaths: vi.fn(() => []),
|
||||
restoreSessionPaths: vi.fn(),
|
||||
attachRelay: vi.fn(),
|
||||
loadStoredSession: vi.fn(() => null),
|
||||
clearStoredSession: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
|
@ -166,17 +176,18 @@ describe("useWizardConnect", () => {
|
|||
expect(secondResult).toBe(false);
|
||||
});
|
||||
|
||||
it("saves credentials to localStorage on connect when persistSession is true", async () => {
|
||||
it("calls attachRelay on connect when persistSession is true", async () => {
|
||||
const { DappConnectionManager } = vi.mocked(
|
||||
await import("@wizardconnect/dapp"),
|
||||
);
|
||||
|
||||
const { result } = await renderHook({ persistSession: true });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
const stored = localStorage.getItem(SESSION_KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.privateKey).toBe("a".repeat(64));
|
||||
expect(parsed.secret).toBe("c".repeat(16));
|
||||
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
|
||||
expect(mgrInstance.attachRelay).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not save credentials when persistSession is false", async () => {
|
||||
|
|
@ -188,12 +199,17 @@ describe("useWizardConnect", () => {
|
|||
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("disconnect() clears state and localStorage", async () => {
|
||||
it("disconnect() clears state and calls clearStoredSession", async () => {
|
||||
const { DappConnectionManager } = vi.mocked(
|
||||
await import("@wizardconnect/dapp"),
|
||||
);
|
||||
|
||||
const { result } = await renderHook({ persistSession: true });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
expect(localStorage.getItem(SESSION_KEY)).not.toBeNull();
|
||||
|
||||
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
|
||||
|
||||
await result.current.disconnect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
|
@ -202,7 +218,7 @@ describe("useWizardConnect", () => {
|
|||
expect(result.current.manager).toBeNull();
|
||||
expect(result.current.uri).toBeNull();
|
||||
expect(result.current.qrUri).toBeNull();
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
expect(mgrInstance.clearStoredSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
|
||||
|
|
@ -222,13 +238,15 @@ describe("useWizardConnect", () => {
|
|||
|
||||
// Should pass existing credentials
|
||||
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
|
||||
expect(callArgs[1]).toEqual({
|
||||
existingCredentials: {
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
},
|
||||
});
|
||||
expect(callArgs[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
existingCredentials: {
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not auto-reconnect when stored session lacks walletPublicKey", async () => {
|
||||
|
|
@ -279,10 +297,11 @@ describe("useWizardConnect", () => {
|
|||
expect(DappConnectionManager).toHaveBeenCalledWith(
|
||||
"My Dapp",
|
||||
"https://example.com/icon.png",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores cached xpub paths on auto-reconnect", async () => {
|
||||
it("passes session option with stored paths to DappConnectionManager", async () => {
|
||||
const testPaths = [
|
||||
{ name: "receive" as const, xpub: "xpub6test1" },
|
||||
{ name: "change" as const, xpub: "xpub6test2" },
|
||||
|
|
@ -304,30 +323,30 @@ describe("useWizardConnect", () => {
|
|||
await renderHook({ persistSession: true });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
// Manager should have been created and restoreSessionPaths called
|
||||
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
|
||||
expect(mgrInstance.restoreSessionPaths).toHaveBeenCalledWith(testPaths);
|
||||
});
|
||||
|
||||
it("does not call restoreSessionPaths when no cached paths", async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
// Manager is constructed with session option so it can auto-restore paths
|
||||
expect(DappConnectionManager).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
session: expect.objectContaining({ key: SESSION_KEY }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables session when persistSession is false", async () => {
|
||||
const { DappConnectionManager } = vi.mocked(
|
||||
await import("@wizardconnect/dapp"),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: true });
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
|
||||
expect(mgrInstance.restoreSessionPaths).not.toHaveBeenCalled();
|
||||
expect(DappConnectionManager).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ session: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes relayUrls to initiateDappRelay", async () => {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import {
|
|||
initiateDappRelay,
|
||||
type DappRelayResult,
|
||||
type RelayUpdatePayload,
|
||||
type PathXpub,
|
||||
binToHex,
|
||||
} from "@wizardconnect/core";
|
||||
import { DappConnectionManager } from "@wizardconnect/dapp";
|
||||
import { DappConnectionManager, loadSession } from "@wizardconnect/dapp";
|
||||
import type {
|
||||
UseWizardConnectOptions,
|
||||
UseWizardConnectResult,
|
||||
|
|
@ -19,37 +17,6 @@ import type {
|
|||
|
||||
const DEFAULT_SESSION_KEY = "wizardconnect-session";
|
||||
|
||||
interface StoredSession {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
walletPublicKey?: string;
|
||||
/** Raw xpub paths from wallet_ready, persisted for offline pubkey derivation. */
|
||||
paths?: PathXpub[];
|
||||
}
|
||||
|
||||
function loadSession(key: string): StoredSession | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as StoredSession;
|
||||
if (!parsed.privateKey || !parsed.secret) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSession(key: string, session: StoredSession): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(key, JSON.stringify(session));
|
||||
}
|
||||
|
||||
function clearSession(key: string): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that encapsulates the WizardConnect relay lifecycle.
|
||||
*
|
||||
|
|
@ -67,6 +34,7 @@ export function useWizardConnect(
|
|||
relayUrls,
|
||||
sessionKey = DEFAULT_SESSION_KEY,
|
||||
persistSession = true,
|
||||
storage,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<WizardConnectState>("idle");
|
||||
|
|
@ -92,7 +60,9 @@ export function useWizardConnect(
|
|||
setError(null);
|
||||
setState("connecting");
|
||||
|
||||
const mgr = new DappConnectionManager(dappName, dappIcon);
|
||||
const mgr = new DappConnectionManager(dappName, dappIcon, {
|
||||
session: persistSession ? { key: sessionKey, storage } : false,
|
||||
});
|
||||
managerRef.current = mgr;
|
||||
setManager(mgr);
|
||||
|
||||
|
|
@ -100,24 +70,15 @@ export function useWizardConnect(
|
|||
setWalletName(mgr.walletName);
|
||||
setWalletIcon(mgr.walletIcon);
|
||||
setState("connected");
|
||||
|
||||
// Persist xpub paths so getPubkey works on next page load
|
||||
if (persistSession) {
|
||||
const paths = mgr.getSessionPaths();
|
||||
if (paths.length > 0) {
|
||||
const stored = loadSession(sessionKey);
|
||||
if (stored) {
|
||||
stored.paths = paths;
|
||||
saveSession(sessionKey, stored);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mgr.on("disconnect", () => {
|
||||
setState("disconnected");
|
||||
setWalletName(null);
|
||||
setWalletIcon(null);
|
||||
mgr.clearStoredSession();
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -132,31 +93,12 @@ export function useWizardConnect(
|
|||
);
|
||||
|
||||
relayRef.current = relay;
|
||||
setUri(relay.uri);
|
||||
setQrUri(relay.qrUri);
|
||||
|
||||
if (persistSession) {
|
||||
// Merge with existing session to preserve walletPublicKey and paths
|
||||
const existing = loadSession(sessionKey);
|
||||
saveSession(sessionKey, {
|
||||
...existing,
|
||||
privateKey: relay.credentials.privateKey,
|
||||
secret: relay.credentials.secret,
|
||||
});
|
||||
// Only expose URI for new connections (QR pairing), not reconnects
|
||||
if (!existingCredentials) {
|
||||
setUri(relay.uri);
|
||||
setQrUri(relay.qrUri);
|
||||
}
|
||||
|
||||
relay.events.on(
|
||||
"keyexchangecomplete",
|
||||
(walletPublicKey: Uint8Array) => {
|
||||
if (persistSession) {
|
||||
const stored = loadSession(sessionKey);
|
||||
if (stored) {
|
||||
stored.walletPublicKey = binToHex(walletPublicKey);
|
||||
saveSession(sessionKey, stored);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
mgr.attachRelay(relay);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
|
@ -167,7 +109,7 @@ export function useWizardConnect(
|
|||
return false;
|
||||
}
|
||||
},
|
||||
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession],
|
||||
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession, storage],
|
||||
);
|
||||
|
||||
const connect = useCallback((): boolean => {
|
||||
|
|
@ -182,6 +124,7 @@ export function useWizardConnect(
|
|||
} finally {
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
managerRef.current?.clearStoredSession();
|
||||
managerRef.current = null;
|
||||
setManager(null);
|
||||
setUri(null);
|
||||
|
|
@ -189,11 +132,8 @@ export function useWizardConnect(
|
|||
setWalletName(null);
|
||||
setWalletIcon(null);
|
||||
setState("idle");
|
||||
if (persistSession) {
|
||||
clearSession(sessionKey);
|
||||
}
|
||||
}
|
||||
}, [persistSession, sessionKey]);
|
||||
}, []);
|
||||
|
||||
// Auto-reconnect on mount if a stored session exists
|
||||
useEffect(() => {
|
||||
|
|
@ -201,39 +141,20 @@ export function useWizardConnect(
|
|||
if (!persistSession) return;
|
||||
autoReconnectAttempted.current = true;
|
||||
|
||||
const stored = loadSession(sessionKey);
|
||||
console.log(
|
||||
"[wizardconnect/react] auto-reconnect: stored session:",
|
||||
stored
|
||||
? `walletPublicKey=${!!stored.walletPublicKey}, paths=${stored.paths?.length ?? 0}`
|
||||
: "null",
|
||||
);
|
||||
// Read session before creating the manager (need credentials for startRelay)
|
||||
const stored = loadSession(sessionKey, storage);
|
||||
if (!stored || !stored.walletPublicKey) return;
|
||||
|
||||
const started = startRelay({
|
||||
if (stored.walletName) {
|
||||
setWalletName(stored.walletName);
|
||||
}
|
||||
|
||||
startRelay({
|
||||
privateKey: stored.privateKey,
|
||||
secret: stored.secret,
|
||||
walletPublicKey: stored.walletPublicKey,
|
||||
});
|
||||
|
||||
// Restore cached xpub paths so getPubkey works before wallet_ready
|
||||
if (started && stored.paths?.length && managerRef.current) {
|
||||
try {
|
||||
managerRef.current.restoreSessionPaths(stored.paths);
|
||||
} catch (e) {
|
||||
// Corrupt cached paths — clear them but don't block reconnect
|
||||
console.warn(
|
||||
"[wizardconnect/react] Failed to restore cached xpub paths:",
|
||||
e,
|
||||
);
|
||||
const refreshed = loadSession(sessionKey);
|
||||
if (refreshed) {
|
||||
delete refreshed.paths;
|
||||
saveSession(sessionKey, refreshed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [persistSession, sessionKey, startRelay]);
|
||||
}, [persistSession, sessionKey, storage, startRelay]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export type {
|
|||
UseWizardConnectResult,
|
||||
WizardConnectState,
|
||||
} from "./types.js";
|
||||
export type { SessionStorage } from "@wizardconnect/dapp";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import type { DappConnectionManager } from "@wizardconnect/dapp";
|
||||
import type {
|
||||
DappConnectionManager,
|
||||
SessionStorage,
|
||||
} from "@wizardconnect/dapp";
|
||||
|
||||
// ---- QR Code ----
|
||||
|
||||
|
|
@ -91,6 +94,8 @@ export interface UseWizardConnectOptions {
|
|||
sessionKey?: string;
|
||||
/** Whether to persist session for auto-reconnect. Default: true */
|
||||
persistSession?: boolean;
|
||||
/** Custom storage backend. Defaults to localStorage. */
|
||||
storage?: SessionStorage;
|
||||
}
|
||||
|
||||
export interface UseWizardConnectResult {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue