Compare commits
1 commit
master
...
uri-to-key
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff77584fe |
71 changed files with 1181 additions and 7342 deletions
|
|
@ -21,7 +21,6 @@ test:
|
|||
extends: .node-common
|
||||
script:
|
||||
- npm install
|
||||
- npm run build
|
||||
- npm run test
|
||||
|
||||
build:
|
||||
|
|
@ -51,14 +50,12 @@ test-integration:
|
|||
|
||||
publish:
|
||||
extends: .node-common
|
||||
# npm trusted publishing via GitLab OIDC — no NPM_TOKEN needed.
|
||||
# npm trusted publishing via GitLab OIDC — requires instance runner for OIDC support.
|
||||
# Each @wizardconnect/* package must be linked to this GitLab project
|
||||
# on npmjs.com: package Settings > Publishing access > Trusted publishers.
|
||||
tags:
|
||||
- saas-linux-small-amd64 # force gitlab instance runner (not self hosted)
|
||||
- saas-linux-small-amd64 # force gitlab instance runner
|
||||
id_tokens:
|
||||
NPM_ID_TOKEN:
|
||||
aud: "npm:registry.npmjs.org"
|
||||
SIGSTORE_ID_TOKEN:
|
||||
aud: sigstore
|
||||
script:
|
||||
|
|
@ -91,10 +88,8 @@ pages:
|
|||
force-publish:
|
||||
extends: .node-common
|
||||
tags:
|
||||
- saas-linux-small-amd64 # force gitlab instance runner (not self hosted)
|
||||
- saas-linux-small-amd64 # force gitlab instance runners
|
||||
id_tokens:
|
||||
NPM_ID_TOKEN:
|
||||
aud: "npm:registry.npmjs.org"
|
||||
SIGSTORE_ID_TOKEN:
|
||||
aud: sigstore
|
||||
script:
|
||||
|
|
|
|||
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -19,14 +19,10 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
|
|||
- Should cover all edge cases in pure logic (state managers, message builders, URI encoding, etc.)
|
||||
|
||||
**Integration tests** (`npm run test:integration` in a package):
|
||||
- Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
|
||||
- Hit the real relay at `wss://relay.cauldron.quest:443`
|
||||
- Test the full protocol handshake end-to-end
|
||||
- Located in `src/integration/*.test.ts` (wallet is the only package with them today)
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially
|
||||
(`singleFork`) to avoid relay contention, and with `retry: 2` — these hit third-party
|
||||
relays, so a dropped connection is an environment failure rather than a regression
|
||||
- A failure that reproduces locally is real; one that does not is usually the relay.
|
||||
Check whether the same test passed on an earlier pipeline before assuming a regression
|
||||
- Located in `src/__tests__/*.integration.test.ts`
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
|
||||
- Must pass before any release
|
||||
|
||||
### Running tests
|
||||
|
|
@ -64,23 +60,17 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
|
|||
| File | Update when… |
|
||||
|------|-------------|
|
||||
| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change |
|
||||
| `docs/extensions.md` | Extension system, `WalletAdapter` extension hooks, known extensions, or custom message conventions change |
|
||||
| `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes |
|
||||
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
|
||||
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |
|
||||
| `docs/dapp.md` | `DappConnectionManager` API or session lifecycle changes |
|
||||
| `docs/react.md` | React components, hooks, or QR dialog API changes |
|
||||
| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes |
|
||||
| `docs/xpub-sharing.md` | xpub sharing rationale, security model, or comparison with other protocols changes |
|
||||
| `docs/serialization.md` | Relay JSON encoding (`sourceOutputToRelay`, `toBigInt`, `toUint8Array`, etc.) changes |
|
||||
| `docs/index.md` | New top-level docs files are added |
|
||||
|
||||
## Packages
|
||||
|
||||
- `@wizardconnect/core` — transport + protocol primitives (relay client, key exchange, hdwalletv1 message types)
|
||||
- `@wizardconnect/dapp` — dapp-side helpers (`DappConnectionManager`, `DappPubkeyStateManager`)
|
||||
- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`)
|
||||
- `@wizardconnect/react` — React components and hooks (`WizardConnectQRDialog`, `AlphanumericQRCode`, `useWizardConnect`)
|
||||
- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes)
|
||||
|
||||
## Build
|
||||
|
|
@ -91,13 +81,3 @@ npm run build # builds all packages in dependency order
|
|||
```
|
||||
|
||||
Packages must be built before integration tests run (tests import from `dist/`).
|
||||
|
||||
## Releases
|
||||
|
||||
The `publish` CI job runs on `master` only, via `contrib/auto-publish.js`.
|
||||
|
||||
**The `version` in each `package.json` is a floor, not the shipped version** — all declare
|
||||
`0.2.0` while npm carries higher patches. Use `npm view @wizardconnect/<pkg> version`.
|
||||
|
||||
The lockfile is not published, so clearing a dependency advisory means bumping the declared
|
||||
range in `package.json`, not just `package-lock.json`.
|
||||
|
|
|
|||
192
README.md
192
README.md
|
|
@ -1,192 +0,0 @@
|
|||
# WizardConnect
|
||||
|
||||
A dapp-to-wallet protocol built for Bitcoin Cash.
|
||||
|
||||
WizardConnect lets a dapp scan a wallet's QR code and immediately start
|
||||
deriving addresses, constructing transactions, and requesting signatures —
|
||||
no seed phrases, no trusted servers, no round trips.
|
||||
|
||||
## Why not WalletConnect?
|
||||
|
||||
WalletConnect was built for Ethereum, where a wallet is a single address. BCH
|
||||
wallets are HD wallets with thousands of addresses for privacy. Forcing an
|
||||
Ethereum protocol onto BCH means:
|
||||
|
||||
- **One address per session.** WalletConnect gives the dapp a single address.
|
||||
That breaks the HD wallet model and destroys the privacy that multiple
|
||||
addresses provide.
|
||||
- **Unreliable on BCH.** BCH support in WalletConnect is a second-class
|
||||
citizen — a bridge on top of an Ethereum-native protocol. Connections drop,
|
||||
sessions fail to restore, and there's no community maintaining it for BCH.
|
||||
|
||||
WizardConnect replaces all of this with a protocol designed from the ground up
|
||||
for UTXO chains and HD wallets.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Wallet Relay Dapp
|
||||
│ │ │
|
||||
│ ◄───── QR code scan ────── │ ◄──── shows QR (wiz://...) │
|
||||
│ │ │
|
||||
│ wallet_ready ──────────────►│──────────────────────────────►│
|
||||
│ (xpubs + key exchange) │ │
|
||||
│ │ derive addresses │
|
||||
│ │ locally from xpubs │
|
||||
│ │ (no more round trips)│
|
||||
│ │ │
|
||||
│ ◄──────────────────────────│◄──── sign_transaction_request │
|
||||
│ user approves on wallet │ │
|
||||
│ sign_transaction_response──►│──────────────────────────────►│
|
||||
│ │ broadcast │
|
||||
```
|
||||
|
||||
1. The dapp generates a `wiz://` URI and displays it as a QR code.
|
||||
2. The wallet scans the QR, connects to the relay, and sends its xpubs.
|
||||
3. The dapp derives all addresses locally — no further contact with the wallet
|
||||
needed for address generation.
|
||||
4. When the dapp needs a signature, it sends a request. The user approves on
|
||||
the wallet. The signed transaction comes back.
|
||||
|
||||
All communication is end-to-end encrypted via Nostr NIP-17 gift wrap. The
|
||||
relay sees only ciphertext.
|
||||
|
||||
## Key features
|
||||
|
||||
**Full HD wallet support.** The wallet shares BIP32 xpubs for specific
|
||||
derivation paths (receive, change, DeFi). The dapp derives unlimited
|
||||
addresses locally. No more single-address sessions.
|
||||
|
||||
**Zero round trips for addresses.** After the initial handshake, the dapp
|
||||
never needs to ask the wallet for a public key. It derives them on demand from
|
||||
the xpubs, in microseconds.
|
||||
|
||||
**Decentralized transport.** Built on Nostr — an open protocol with hundreds
|
||||
of public relays. No proprietary bridge server. Users can point to any relay
|
||||
for additional privacy or self-host their own.
|
||||
|
||||
**Reconnection-proof.** Both sides can disconnect and reconnect independently
|
||||
(app switch, browser refresh, network drop) and converge back to a live
|
||||
session without user action.
|
||||
|
||||
**Extensible.** The protocol negotiates capabilities during the handshake.
|
||||
`hdwalletv1` is the first protocol; future protocols (multisig, post-quantum,
|
||||
token-aware) can be added without breaking existing connections.
|
||||
|
||||
**Open source (LGPL-3.0).** Free to use in commercial and non-commercial
|
||||
applications. Modifications to the library itself must be shared back.
|
||||
|
||||
## Packages
|
||||
|
||||
```
|
||||
npm install @wizardconnect/core # transport, protocol types, key exchange
|
||||
npm install @wizardconnect/dapp # dapp-side session + address derivation
|
||||
npm install @wizardconnect/wallet # wallet-side connection + signing
|
||||
```
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@wizardconnect/core` | Relay client, NIP-17 encryption, URI encoding, protocol message types |
|
||||
| `@wizardconnect/dapp` | `DappConnectionManager`, `DappPubkeyStateManager` — session management and on-demand pubkey derivation |
|
||||
| `@wizardconnect/wallet` | `WalletConnectionManager`, `WalletAdapter` interface — multi-connection management and sign request dispatch |
|
||||
|
||||
## Quick start: dapp
|
||||
|
||||
```typescript
|
||||
import { initiateDappRelay } from "@wizardconnect/core";
|
||||
import { DappConnectionManager } from "@wizardconnect/dapp";
|
||||
|
||||
const dapp = new DappConnectionManager("My Dapp", "https://example.com/icon.png");
|
||||
|
||||
const relay = initiateDappRelay(
|
||||
(payload) => dapp.updateConnection(payload.client, payload.status),
|
||||
{ explicitRelayUrls: ["wss://relay.riften.net:443"] },
|
||||
);
|
||||
|
||||
// Display relay.uri as a QR code for the wallet to scan
|
||||
|
||||
dapp.on("walletready", () => {
|
||||
// Connected! Derive addresses locally:
|
||||
const receivePubkey = dapp.getPubkey(0, 0n); // receive path, index 0
|
||||
const changePubkey = dapp.getPubkey(1, 0n); // change path, index 0
|
||||
});
|
||||
```
|
||||
|
||||
## Quick start: wallet
|
||||
|
||||
```typescript
|
||||
import { WalletConnectionManager } from "@wizardconnect/wallet";
|
||||
|
||||
const manager = new WalletConnectionManager(myWalletAdapter);
|
||||
|
||||
// When the user scans a dapp's QR code:
|
||||
const connectionId = manager.connect("wiz://?p=...&s=...");
|
||||
|
||||
// When a sign request arrives, show it to the user:
|
||||
manager.on("pendingSignRequest", async ({ connectionId, request }) => {
|
||||
const approved = await showApprovalUI(request);
|
||||
if (approved) {
|
||||
await manager.sendSignResponse(connectionId, request.sequence, signedTxHex);
|
||||
} else {
|
||||
await manager.sendSignError(connectionId, request.sequence, "User rejected");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
See [wallet integration guide](docs/wallet.md) for `WalletAdapter` implementation details.
|
||||
|
||||
## Privacy model
|
||||
|
||||
WizardConnect shares xpubs at the **chain level**, not the account level.
|
||||
A dapp receiving the receive xpub can derive receive addresses but cannot
|
||||
derive change addresses or any other internal wallet activity. This is the
|
||||
same level of key material used by watch-only wallets.
|
||||
|
||||
For wallets that want stronger isolation, the protocol supports per-session
|
||||
xpub rotation — the dapp sees only the xpub node, never the derivation path.
|
||||
|
||||
See [xpub sharing: why it's safe](docs/xpub-sharing.md) for a full discussion.
|
||||
|
||||
## Security
|
||||
|
||||
- Private keys never leave the wallet. Signing happens on-device.
|
||||
- All relay traffic is end-to-end encrypted (NIP-17 gift wrap). The relay
|
||||
cannot read messages.
|
||||
- Key exchange includes an 8-byte shared secret (embedded in the QR code) for
|
||||
MITM prevention.
|
||||
- No trusted intermediary. The relay is a dumb message broker — compromise it
|
||||
and you get ciphertext.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Link |
|
||||
|-------|------|
|
||||
| Protocol messages and handshake | [protocol.md](docs/protocol.md) |
|
||||
| Connection URI and key exchange | [connection-uri.md](docs/connection-uri.md) |
|
||||
| Relay transport and encryption | [transport.md](docs/transport.md) |
|
||||
| Wallet integration guide | [wallet.md](docs/wallet.md) |
|
||||
| Dapp integration guide | [dapp.md](docs/dapp.md) |
|
||||
| xpub delivery and derivation | [pubkey-derivation.md](docs/pubkey-derivation.md) |
|
||||
| xpub sharing: why it's safe | [xpub-sharing.md](docs/xpub-sharing.md) |
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # builds all packages in dependency order
|
||||
npm run test # unit tests (fast, no network)
|
||||
```
|
||||
|
||||
Integration tests hit a live relay:
|
||||
|
||||
```bash
|
||||
cd packages/wallet && npm run test:integration
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[LGPL-3.0-or-later](https://www.gnu.org/licenses/lgpl-3.0.html). Free for
|
||||
commercial and non-commercial use. Modifications to the library must be
|
||||
released under the same license.
|
||||
|
||||
Built by [Riften Labs](https://riftenlabs.com).
|
||||
|
|
@ -94,14 +94,6 @@ function getCurrentVersion(packagePath) {
|
|||
return packageJson.version;
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const [aMajor, aMinor, aPatch] = a.split('.').map(Number);
|
||||
const [bMajor, bMinor, bPatch] = b.split('.').map(Number);
|
||||
if (aMajor !== bMajor) return aMajor - bMajor;
|
||||
if (aMinor !== bMinor) return aMinor - bMinor;
|
||||
return aPatch - bPatch;
|
||||
}
|
||||
|
||||
function bumpVersion(currentVersion, latestNpmVersion) {
|
||||
const [major, minor, patch] = currentVersion.split('.').map(Number);
|
||||
|
||||
|
|
@ -110,13 +102,12 @@ function bumpVersion(currentVersion, latestNpmVersion) {
|
|||
}
|
||||
|
||||
const [npmMajor, npmMinor, npmPatch] = latestNpmVersion.split('.').map(Number);
|
||||
const cmp = compareVersions(currentVersion, latestNpmVersion);
|
||||
|
||||
if (cmp > 0) {
|
||||
if (currentVersion > latestNpmVersion) {
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
if (cmp < 0) {
|
||||
if (latestNpmVersion > currentVersion) {
|
||||
return `${npmMajor}.${npmMinor}.${npmPatch + 1}`;
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +130,7 @@ function publishPackage(packagePath, packageName) {
|
|||
log(`Publishing ${packageName}`);
|
||||
|
||||
try {
|
||||
execCommand('npm publish --access public', packagePath);
|
||||
execCommand('npm publish --provenance --access public', packagePath);
|
||||
log(`Successfully published ${packageName}`);
|
||||
} catch (error) {
|
||||
log(`Failed to publish ${packageName}: ${error.message}`);
|
||||
|
|
|
|||
|
|
@ -78,14 +78,6 @@ function getCurrentVersion(packagePath) {
|
|||
return packageJson.version;
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const [aMajor, aMinor, aPatch] = a.split('.').map(Number);
|
||||
const [bMajor, bMinor, bPatch] = b.split('.').map(Number);
|
||||
if (aMajor !== bMajor) return aMajor - bMajor;
|
||||
if (aMinor !== bMinor) return aMinor - bMinor;
|
||||
return aPatch - bPatch;
|
||||
}
|
||||
|
||||
function bumpVersion(currentVersion, latestNpmVersion) {
|
||||
const [major, minor, patch] = currentVersion.split('.').map(Number);
|
||||
|
||||
|
|
@ -94,13 +86,12 @@ function bumpVersion(currentVersion, latestNpmVersion) {
|
|||
}
|
||||
|
||||
const [npmMajor, npmMinor, npmPatch] = latestNpmVersion.split('.').map(Number);
|
||||
const cmp = compareVersions(currentVersion, latestNpmVersion);
|
||||
|
||||
if (cmp > 0) {
|
||||
if (currentVersion > latestNpmVersion) {
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
if (cmp < 0) {
|
||||
if (latestNpmVersion > currentVersion) {
|
||||
return `${npmMajor}.${npmMinor}.${npmPatch + 1}`;
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +114,7 @@ function publishPackage(packagePath, packageName) {
|
|||
log(`Publishing ${packageName}`);
|
||||
|
||||
try {
|
||||
execCommand('npm publish --access public --loglevel verbose', packagePath);
|
||||
execCommand('npm publish --provenance --access public', packagePath);
|
||||
log(`Successfully published ${packageName}`);
|
||||
} catch (error) {
|
||||
log(`Failed to publish ${packageName}: ${error.message}`);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ wiz://<hostname>:<port>?p=<pubkey_bech32>&s=<secret_bech32>&pr=<protocol>
|
|||
|-----------|----------|------|---------|
|
||||
| `p` | bech32-padded | 32 bytes | Dapp's Nostr public key (x-only secp256k1) |
|
||||
| `s` | bech32-padded | 8 bytes | Shared secret for key exchange verification |
|
||||
| `hostname` | URL authority | — | Relay hostname (default: `relay.riften.net`) |
|
||||
| `hostname` | URL authority | — | Relay hostname (default: `relay.cauldron.quest`) |
|
||||
| `port` | URL authority | — | Relay port (default: `443`) |
|
||||
| `pr` | query param | — | `ws` or `wss` (default: `wss`, omitted when default) |
|
||||
|
||||
|
|
|
|||
237
docs/dapp.md
237
docs/dapp.md
|
|
@ -17,47 +17,26 @@ 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, options?: {
|
||||
/** Session persistence config. Enabled by default (key: "wizardconnect-session",
|
||||
* storage: localStorage). Pass `false` to disable. */
|
||||
session?: DappSessionOptions | false;
|
||||
})
|
||||
constructor(dappName?: string, dappIcon?: string)
|
||||
|
||||
/** Call from the RelayStatusCallback each time the relay status changes. */
|
||||
/** 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. */
|
||||
updateConnection(client: RelayClient | null, status: RelayStatus): void
|
||||
|
||||
isWalletDiscovered(): boolean
|
||||
|
||||
/** 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>
|
||||
|
||||
/** Low-level: send a fully constructed sign request. */
|
||||
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
|
||||
|
||||
/** 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). */
|
||||
/** Get the next sequence number for a SignTransactionRequest. */
|
||||
nextSequence(): number
|
||||
|
||||
/** Send a UserDisconnect courtesy message to the wallet. */
|
||||
/** Send a sign request and wait for the wallet's response.
|
||||
* Rejects if the wallet returns an error or if the connection drops. */
|
||||
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
|
||||
|
||||
/** Send a UserDisconnect courtesy message to the wallet.
|
||||
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
|
||||
sendDisconnect(message?: string): Promise<void>
|
||||
|
||||
/** Get raw PathXpub[] received in wallet_ready (for caching). */
|
||||
getSessionPaths(): PathXpub[]
|
||||
|
||||
/** 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)
|
||||
|
|
@ -66,10 +45,6 @@ class DappConnectionManager extends EventEmitter {
|
|||
}
|
||||
```
|
||||
|
||||
The `"messagereceived"` event fires for **all** protocol messages, including extension-defined
|
||||
actions. Use it to handle custom messages from wallet extensions. See
|
||||
[extensions.md](extensions.md) for the extension system and graceful degradation patterns.
|
||||
|
||||
## Pubkey state — convenience delegation
|
||||
|
||||
`DappConnectionManager` delegates to `pubkeyState` for all pubkey operations. These methods
|
||||
|
|
@ -97,17 +72,10 @@ removeFromChangeQueue(index: bigint): void
|
|||
|
||||
// Get the stored xpub node (after wallet_ready)
|
||||
getXpubNode(childIndex: number): HdPublicNodeValid | undefined
|
||||
|
||||
// Get raw PathXpub[] from wallet_ready (for caching)
|
||||
getSessionPaths(): PathXpub[]
|
||||
|
||||
// Restore cached PathXpub[] — populates pubkeyState so getPubkey works without wallet_ready
|
||||
restoreSessionPaths(paths: PathXpub[]): void
|
||||
```
|
||||
|
||||
Child index values: `0` = receive, `1` = change, `7` = defi (Cauldron). These are
|
||||
internal to the dapp layer; use `childIndexOfPathName()` to convert from `PathName` if needed.
|
||||
It returns `undefined` for extension path names — callers should skip those.
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
|
|
@ -122,12 +90,11 @@ 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);
|
||||
```
|
||||
|
|
@ -154,66 +121,36 @@ 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: { ... },
|
||||
inputPaths: [[0, "receive", 0]],
|
||||
transaction: {
|
||||
transaction: { inputs, outputs, version: 2, locktime: 0 },
|
||||
sourceOutputs,
|
||||
userPrompt: "Confirm swap",
|
||||
broadcast: true,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await dappMgr.sendSignRequest(request);
|
||||
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");
|
||||
try {
|
||||
const response = await dappMgr.sendSignRequest(request);
|
||||
console.log("Signed tx:", response.signedTransaction);
|
||||
} catch (err) {
|
||||
console.error("Signing failed:", err.message);
|
||||
}
|
||||
```
|
||||
|
||||
`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, clear session, then tear down the relay
|
||||
dappMgr.clearStoredSession();
|
||||
// Dapp-initiated: send courtesy message, then tear down the relay
|
||||
await dappMgr.sendDisconnect("user closed the tab");
|
||||
relay.cleanup();
|
||||
|
||||
|
|
@ -234,86 +171,6 @@ The `disconnect` event fires in two cases:
|
|||
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
|
||||
wallet before emitting the event.
|
||||
|
||||
### Session persistence
|
||||
|
||||
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
|
||||
const relay = initiateDappRelay(callback);
|
||||
dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey
|
||||
|
||||
// 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 });
|
||||
}
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
`updateConnection()` is called on every relay status change. When `status.status === "connected"`,
|
||||
|
|
@ -321,12 +178,6 @@ it calls `onConnected()` which waits for key exchange and then sends a fresh `da
|
|||
`walletDiscovered` flag carries over reconnects (it is only reset by creating a new manager),
|
||||
so the correct `wallet_discovered` value is sent on each reconnect.
|
||||
|
||||
When `wallet_ready` is received and there are pending (unresponded) sign requests, the manager
|
||||
automatically re-sends them. This handles the case where the user triggers a signature in the
|
||||
dapp before the wallet app is open — the relay's time filter would otherwise discard the
|
||||
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
|
||||
valid across reconnects.
|
||||
|
||||
## Using initiateDappRelay without DappConnectionManager
|
||||
|
||||
If you need lower-level control (e.g., in the test-cli), you can work directly with the
|
||||
|
|
@ -350,24 +201,14 @@ relay.events.on("keyexchangecomplete", async (walletPubkey) => {
|
|||
});
|
||||
```
|
||||
|
||||
## Using with React
|
||||
## Cauldron (cauldron-beta) implementation notes
|
||||
|
||||
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).
|
||||
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
|
||||
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,213 +0,0 @@
|
|||
# Extensions
|
||||
|
||||
This document covers `hdwalletv1` protocol-level extensions — optional capabilities that extend
|
||||
the application protocol (extra path names, custom message actions, per-wallet features). For
|
||||
transport-level capabilities that apply below the application protocol regardless of which
|
||||
protocol is in use (chunking, future: compression), see
|
||||
[transport.md § Transport-level extensions](transport.md#transport-level-extensions).
|
||||
|
||||
The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate
|
||||
additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible:
|
||||
existing wallets and dapps that don't know about extensions continue to work unchanged.
|
||||
|
||||
## How extensions work
|
||||
|
||||
Extensions use three mechanisms, all of which are optional and additive:
|
||||
|
||||
### 1. Session extensions field
|
||||
|
||||
Wallets advertise supported extensions in the `wallet_ready` handshake via an `extensions` field
|
||||
on the `Hdwalletv1Session` object:
|
||||
|
||||
```typescript
|
||||
interface Hdwalletv1Session {
|
||||
paths: PathXpub[];
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
Each key in `extensions` is an extension name. Its **presence** indicates the wallet supports that
|
||||
extension. The value carries extension-specific handshake data (derivation paths to the hardened
|
||||
gate where the wallet exports xpubs), or `{}` if no data is needed.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"paths": [
|
||||
{ "name": "receive", "xpub": "xpub6..." },
|
||||
{ "name": "change", "xpub": "xpub6..." },
|
||||
{ "name": "defi", "xpub": "xpub6..." },
|
||||
{ "name": "stealth_spend", "xpub": "xpub6..." },
|
||||
{ "name": "stealth_scan", "xpub": "xpub6..." },
|
||||
{ "name": "rpa_spend", "xpub": "xpub6..." },
|
||||
{ "name": "rpa_scan", "xpub": "xpub6..." }
|
||||
],
|
||||
"extensions": {
|
||||
"bch_stealth_bip352": {
|
||||
"spend_path": "m/352'/145'/0'/0'",
|
||||
"scan_path": "m/352'/145'/0'/1'"
|
||||
},
|
||||
"rpa_bip47": {
|
||||
"spend_path": "m/47'/145'/0'/0'",
|
||||
"scan_path": "m/47'/145'/0'/1'"
|
||||
},
|
||||
"decrypt": { "public_key": "02abc...", "scheme": "ecies" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Additional path names
|
||||
|
||||
`PathName` is an open string type. Wallets may include paths beyond the well-known
|
||||
`receive`/`change`/`defi` set. Extension-defined paths are carried in the standard `paths` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"paths": [
|
||||
{ "name": "receive", "xpub": "xpub6..." },
|
||||
{ "name": "change", "xpub": "xpub6..." },
|
||||
{ "name": "stealth_spend", "xpub": "xpub6..." },
|
||||
{ "name": "stealth_scan", "xpub": "xpub6..." },
|
||||
{ "name": "rpa_spend", "xpub": "xpub6..." },
|
||||
{ "name": "rpa_scan", "xpub": "xpub6..." }
|
||||
],
|
||||
"extensions": {
|
||||
"bch_stealth_bip352": {
|
||||
"spend_path": "m/352'/145'/0'/0'",
|
||||
"scan_path": "m/352'/145'/0'/1'"
|
||||
},
|
||||
"rpa_bip47": {
|
||||
"spend_path": "m/47'/145'/0'/0'",
|
||||
"scan_path": "m/47'/145'/0'/1'"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dapps should ignore path names they do not recognize. The standard pubkey derivation logic
|
||||
(`DappPubkeyStateManager`) automatically skips unknown paths.
|
||||
|
||||
### 3. Custom message actions
|
||||
|
||||
Extensions may define new message action strings beyond the well-known set. Custom messages follow
|
||||
the standard `ProtocolMessage` shape (`action` + `time`) and use the existing relay transport.
|
||||
|
||||
Convention for request/response operations:
|
||||
|
||||
```typescript
|
||||
// Request (dapp → wallet):
|
||||
{ action: "<operation>_request", sequence: number, time: number, ...params }
|
||||
|
||||
// Response (wallet → dapp):
|
||||
{ action: "<operation>_response", sequence: number, time: number, ...result }
|
||||
```
|
||||
|
||||
The `sequence` field ties responses to requests, matching the pattern used by
|
||||
`sign_transaction_request`/`sign_transaction_response`.
|
||||
|
||||
**Wallet side**: custom messages are emitted via the `"message"` event on
|
||||
`WalletConnectionManager`:
|
||||
|
||||
```typescript
|
||||
manager.on("message", (connectionId, msg) => {
|
||||
if (msg.action === "decrypt_request") {
|
||||
// handle decrypt request
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Dapp side**: all messages (including custom ones) are emitted via the `"messagereceived"` event
|
||||
on `DappConnectionManager`:
|
||||
|
||||
```typescript
|
||||
manager.on("messagereceived", (msg) => {
|
||||
if (msg.action === "decrypt_response") {
|
||||
// handle decrypt response
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Implementing an extension (wallet side)
|
||||
|
||||
Wallets advertise extensions by implementing optional methods on `WalletAdapter`:
|
||||
|
||||
```typescript
|
||||
interface WalletAdapter {
|
||||
// ... core methods ...
|
||||
|
||||
/** Additional paths to include in the session (e.g. stealth_scan). */
|
||||
getAdditionalPaths?(): PathXpub[];
|
||||
|
||||
/** Extension data for the session handshake. */
|
||||
getExtensions?(): Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
const adapter: WalletAdapter = {
|
||||
// ... core implementation ...
|
||||
|
||||
getAdditionalPaths() {
|
||||
return [
|
||||
{ name: "stealth_spend", xpub: deriveXpub("m/352'/145'/0'/0'") },
|
||||
{ name: "stealth_scan", xpub: deriveXpub("m/352'/145'/0'/1'") },
|
||||
{ name: "rpa_spend", xpub: deriveXpub("m/47'/145'/0'/0'") },
|
||||
{ name: "rpa_scan", xpub: deriveXpub("m/47'/145'/0'/1'") },
|
||||
];
|
||||
},
|
||||
|
||||
getExtensions() {
|
||||
return {
|
||||
"bch_stealth_bip352": {
|
||||
"spend_path": "m/352'/145'/0'/0'",
|
||||
"scan_path": "m/352'/145'/0'/1'",
|
||||
},
|
||||
"rpa_bip47": {
|
||||
"spend_path": "m/47'/145'/0'/0'",
|
||||
"scan_path": "m/47'/145'/0'/1'",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Wallets that don't implement these methods produce the same session as before (receive/change/defi
|
||||
paths only, no extensions field).
|
||||
|
||||
## Discovering extensions (dapp side)
|
||||
|
||||
Dapps check for extension support after receiving `wallet_ready`:
|
||||
|
||||
```typescript
|
||||
manager.on("walletready", (msg) => {
|
||||
const session = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
||||
|
||||
if (session.extensions?.bch_stealth_bip352) {
|
||||
const scanPath = session.paths.find(p => p.name === "stealth_scan");
|
||||
const spendPath = session.paths.find(p => p.name === "stealth_spend");
|
||||
// enable stealth address features
|
||||
} else {
|
||||
// show: "Stealth payments require a wallet that supports BCH Stealth (BIP352)"
|
||||
}
|
||||
|
||||
if (session.extensions?.rpa_bip47) {
|
||||
const rpaSpend = session.paths.find(p => p.name === "rpa_spend");
|
||||
const rpaScan = session.paths.find(p => p.name === "rpa_scan");
|
||||
// enable RPA features
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Dapps should degrade gracefully when an extension is absent. If a feature requires an extension the
|
||||
wallet doesn't support, inform the user rather than failing silently.
|
||||
|
||||
## Known extensions
|
||||
|
||||
| Extension name | Path names | Hardened gate paths | Purpose | Status |
|
||||
|---------------|-----------|---------------------|---------|--------|
|
||||
| `bch_stealth_bip352` | `stealth_spend`, `stealth_scan` | `m/352'/145'/0'/0'`, `m/352'/145'/0'/1'` | BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
|
||||
| `rpa_bip47` | `rpa_spend`, `rpa_scan` | `m/47'/145'/0'/0'`, `m/47'/145'/0'/1'` | BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
|
||||
| `decrypt` | — | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends `decrypt_request` messages when the data is needed. | Proposed |
|
||||
|
||||
See the discussions and specifications for each extension as they are formalized.
|
||||
|
|
@ -32,10 +32,6 @@ libwizardconnect/
|
|||
│ DappConnectionManager, DappPubkeyStateManager.
|
||||
│ Single-session dapp helper, on-demand xpub derivation.
|
||||
│
|
||||
├── packages/react — @wizardconnect/react
|
||||
│ React components and hooks for dapp integration.
|
||||
│ QR dialog, useWizardConnect hook.
|
||||
│
|
||||
└── packages/test-cli — @wizardconnect/test-cli (private)
|
||||
CLI for manual and exploratory testing.
|
||||
```
|
||||
|
|
@ -76,11 +72,8 @@ future if needed. You will be prompted to sign the CLA when you open your first
|
|||
| Topic | File |
|
||||
|-------|------|
|
||||
| Protocol messages and handshake | [protocol.md](protocol.md) |
|
||||
| Protocol extensions | [extensions.md](extensions.md) |
|
||||
| Connection URI and key exchange | [connection-uri.md](connection-uri.md) |
|
||||
| Relay transport and encryption | [transport.md](transport.md) |
|
||||
| Wallet integration guide | [wallet.md](wallet.md) |
|
||||
| Dapp integration guide | [dapp.md](dapp.md) |
|
||||
| React components and hooks | [react.md](react.md) |
|
||||
| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) |
|
||||
| xpub sharing: why it's safe | [xpub-sharing.md](xpub-sharing.md) |
|
||||
|
|
|
|||
|
|
@ -36,15 +36,8 @@ sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
|
|||
sign_transaction_response — wallet → dapp, returns signed tx or error
|
||||
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
|
||||
disconnect — either → either, courtesy notification before tearing down
|
||||
chunk — either → either, transport-level. Carries one slice of a larger message
|
||||
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
|
||||
hdwalletv1 semantics. See transport.md.
|
||||
```
|
||||
|
||||
The action names above are the well-known set. Extensions may define additional action strings
|
||||
(e.g. `decrypt_request`, `decrypt_response`). Both sides should ignore unknown actions gracefully.
|
||||
See [extensions.md](extensions.md) for conventions on defining extension messages.
|
||||
|
||||
---
|
||||
|
||||
## Base protocol
|
||||
|
|
@ -124,11 +117,8 @@ a ready message from the other side in this runtime session."
|
|||
"ready" — *even if it already sent one* — because the other side has lost state and needs
|
||||
a fresh delivery.
|
||||
4. The wallet guards against duplicate `wallet_ready` messages within a single connection cycle
|
||||
via `walletReadySentThisCycle`. Both `walletReadySentThisCycle` and `dappDiscovered` reset to
|
||||
`false` on each new connect/reconnect. This ensures the wallet always sends
|
||||
`wallet_ready(dapp_discovered=false)` at the start of a new connection cycle, matching the
|
||||
"Wallet reconnects" scenario. Receiving `dapp_ready(wallet_discovered=false)` also resets
|
||||
`walletReadySentThisCycle`.
|
||||
via `walletReadySentThisCycle`. This resets to `false` on each new connect/reconnect.
|
||||
Receiving `dapp_ready(wallet_discovered=false)` also resets this flag.
|
||||
|
||||
#### Scenarios
|
||||
|
||||
|
|
@ -177,17 +167,12 @@ the wallet.
|
|||
|
||||
```typescript
|
||||
interface Hdwalletv1Session {
|
||||
paths: PathXpub[]; // BIP32 xpubs for each named path
|
||||
extensions?: Record<string, unknown>; // optional extension capabilities and data
|
||||
paths: PathXpub[]; // BIP32 xpubs for receive/change/defi
|
||||
}
|
||||
```
|
||||
|
||||
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
|
||||
|
||||
The `extensions` field is optional. When present, each key is an extension name and its presence
|
||||
indicates the wallet supports that extension. The value carries extension-specific handshake data,
|
||||
or `{}` if no data is needed. See [extensions.md](extensions.md) for the full extension system.
|
||||
|
||||
See [pubkey-derivation.md](pubkey-derivation.md) for the full xpub story.
|
||||
|
||||
### PathXpub
|
||||
|
|
@ -228,26 +213,15 @@ that only the wallet (and internal dapp state) need to know.
|
|||
### PathName
|
||||
|
||||
```typescript
|
||||
type PathName = string;
|
||||
|
||||
// Well-known path names:
|
||||
const PATH_RECEIVE = "receive";
|
||||
const PATH_CHANGE = "change";
|
||||
const PATH_DEFI = "defi";
|
||||
type PathName = "receive" | "change" | "defi";
|
||||
```
|
||||
|
||||
`PathName` is an open string. The well-known values are:
|
||||
|
||||
| Name | Recommended BIP44 path | Purpose |
|
||||
|------|------------------------|---------|
|
||||
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
|
||||
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
|
||||
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
|
||||
|
||||
Wallets may include additional paths via extensions (e.g. `stealth_scan`, `stealth_spend`, `rpa`).
|
||||
Dapps should ignore path names they do not recognize. See [extensions.md](extensions.md) for
|
||||
conventions on defining new path names.
|
||||
|
||||
### sign_transaction_request
|
||||
|
||||
```typescript
|
||||
|
|
@ -255,7 +229,6 @@ interface SignTransactionRequest {
|
|||
action: "sign_transaction_request";
|
||||
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
|
||||
sequence: number;
|
||||
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
|
@ -268,35 +241,6 @@ to match responses to requests.
|
|||
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
|
||||
wallet UI.
|
||||
|
||||
`inputPaths` is a sparse array of `[inputIndex, PathName, addressIndex]` tuples. Each entry identifies
|
||||
the HD derivation path name and address index the dapp used to derive the locking script for the input
|
||||
at position `inputIndex`. Only inputs that require wallet signing need an entry — contract inputs with
|
||||
pre-set unlocking bytecode can be omitted. This allows the wallet to sign each input without scanning
|
||||
or guessing which key was used.
|
||||
|
||||
#### SIGHASH requirement (security-critical)
|
||||
|
||||
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` and **MUST**
|
||||
reject any request that would require different flags.
|
||||
|
||||
`SIGHASH_ALL` ensures the signature commits to the entire transaction (all inputs and all outputs).
|
||||
Without it, an attacker could collect a valid signature and graft it onto a different transaction —
|
||||
for example, using `SIGHASH_NONE` an attacker could replace every output to redirect funds.
|
||||
|
||||
Because `inputPaths` lets the dapp specify which key signs each input, the wallet no longer
|
||||
independently verifies that the key matches the UTXO's locking bytecode. This is safe **only** when
|
||||
`SIGHASH_ALL` is enforced: if the dapp provides a wrong path, the resulting signature is invalid
|
||||
(public key hash mismatch) and the transaction cannot broadcast. Without `SIGHASH_ALL`, a
|
||||
wrong-key signature could still be repurposed in a different transaction context.
|
||||
|
||||
Summary of the flags:
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `SIGHASH_ALL` | Commits to all inputs and outputs — prevents output substitution |
|
||||
| `SIGHASH_FORKID` | Prevents cross-fork replay (BCH ↔ BTC) |
|
||||
| `SIGHASH_UTXOS` | Commits to all input UTXOs — prevents input substitution after signing |
|
||||
|
||||
### sign_transaction_response
|
||||
|
||||
```typescript
|
||||
|
|
@ -312,18 +256,6 @@ interface SignTransactionResponse {
|
|||
The wallet either returns the signed transaction hex or an error string. The dapp rejects the
|
||||
pending Promise associated with the `sequence` in the error case.
|
||||
|
||||
### Re-delivery on reconnect
|
||||
|
||||
If the wallet is not connected (or reconnects) while a `sign_transaction_request` is in flight,
|
||||
the dapp automatically re-sends all pending requests when it receives `wallet_ready`. This
|
||||
handles the common case where the user triggers a transaction in the dapp and then opens the
|
||||
wallet app several seconds later.
|
||||
|
||||
The wallet deduplicates incoming requests by `sequence` number — if it has already emitted
|
||||
`pendingSignRequest` for a given sequence and has not yet responded, the duplicate is silently
|
||||
dropped. The dedup guard is cleared when the wallet sends a response (`sign_transaction_response`)
|
||||
or receives a `sign_cancel`.
|
||||
|
||||
### sign_cancel
|
||||
|
||||
```typescript
|
||||
|
|
@ -358,12 +290,6 @@ Either side may send a `disconnect` message before tearing down the relay connec
|
|||
courtesy notification — the remote side treats the connection as closed immediately upon receipt
|
||||
(no acknowledgement).
|
||||
|
||||
No acknowledgement does not mean fire-and-forget on the sender's side. `relay()` resolves only
|
||||
after the publish has been settled against every configured relay, so the sender must keep the
|
||||
relay connection open until then — closing it first kills the publish in flight and the peer
|
||||
never learns of the disconnect. Sending a courtesy `disconnect` and immediately tearing the
|
||||
transport down is the same as not sending one.
|
||||
|
||||
```typescript
|
||||
enum DisconnectReason {
|
||||
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
|
||||
|
|
@ -379,9 +305,7 @@ interface DisconnectMessage {
|
|||
```
|
||||
|
||||
**Wallet side** (`WalletConnectionManager`):
|
||||
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish
|
||||
settles — bounded, so an unreachable relay cannot hold the socket open. The connection leaves
|
||||
the registry synchronously. See [wallet.md § Sending disconnect](wallet.md#sending-disconnect).
|
||||
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
|
||||
- Incoming `disconnect` emits a `remoteDisconnect` event
|
||||
(`connectionId`, `reason`, `message`) and removes the connection.
|
||||
|
||||
|
|
@ -414,12 +338,10 @@ These are used internally to validate incoming messages before dispatch.
|
|||
## Helper: childIndexOfPathName
|
||||
|
||||
```typescript
|
||||
function childIndexOfPathName(name: PathName): number | undefined
|
||||
// "receive" → 0, "change" → 1, "defi" → 7, unknown → undefined
|
||||
function childIndexOfPathName(name: PathName): number
|
||||
// "receive" → 0, "change" → 1, "defi" → 7
|
||||
```
|
||||
|
||||
The protocol uses string names for paths, but code that manages key state internally (such as
|
||||
`DappPubkeyStateManager`) keys its maps by numeric child index. This helper converts between the
|
||||
two representations for the well-known path names. It returns `undefined` for extension path names
|
||||
(e.g. `"stealth_scan"`). Callers must handle the `undefined` case — typically by skipping the path.
|
||||
It is not a protocol concern — the numeric indices never appear on the wire.
|
||||
two representations. It is not a protocol concern — the numeric indices never appear on the wire.
|
||||
|
|
|
|||
184
docs/react.md
184
docs/react.md
|
|
@ -1,184 +0,0 @@
|
|||
# React integration
|
||||
|
||||
`@wizardconnect/react` provides React components and hooks for dapp developers who want to add
|
||||
WizardConnect wallet connectivity without writing relay boilerplate.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
|
||||
|
||||
function App() {
|
||||
const wc = useWizardConnect({ dappName: "My Dapp" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={wc.connect} disabled={wc.state !== "idle"}>
|
||||
Connect Wallet
|
||||
</button>
|
||||
|
||||
{wc.uri && wc.qrUri && (
|
||||
<WizardConnectQRDialog
|
||||
show={wc.state === "connecting"}
|
||||
onClose={() => wc.disconnect()}
|
||||
uri={wc.uri}
|
||||
qrUri={wc.qrUri}
|
||||
/>
|
||||
)}
|
||||
|
||||
{wc.state === "connected" && (
|
||||
<p>Connected to {wc.walletName}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useWizardConnect
|
||||
|
||||
The `useWizardConnect` hook manages the full relay lifecycle:
|
||||
|
||||
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
|
||||
|
||||
```typescript
|
||||
interface UseWizardConnectResult {
|
||||
state: "idle" | "connecting" | "connected" | "disconnected";
|
||||
manager: DappConnectionManager | null;
|
||||
uri: string | null;
|
||||
qrUri: string | null;
|
||||
walletName: string | null;
|
||||
walletIcon: string | null;
|
||||
connect: () => boolean;
|
||||
disconnect: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Using the manager
|
||||
|
||||
Once `state === "connected"`, the `manager` is a live `DappConnectionManager` from
|
||||
`@wizardconnect/dapp`. Use it to derive addresses and request signatures:
|
||||
|
||||
```typescript
|
||||
// Derive a receive address pubkey
|
||||
const pubkey = wc.manager.getPubkey(0, 0n);
|
||||
|
||||
// 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).
|
||||
|
||||
## WizardConnectQRDialog
|
||||
|
||||
A framework-independent modal dialog for displaying the connection QR code. Uses inline styles
|
||||
(no Tailwind or CSS framework dependency) with a dark theme by default.
|
||||
|
||||
### Customization
|
||||
|
||||
Colors, text, and logos are all customizable via props:
|
||||
|
||||
```tsx
|
||||
<WizardConnectQRDialog
|
||||
show={true}
|
||||
onClose={handleClose}
|
||||
uri={connection.uri}
|
||||
qrUri={connection.qrUri}
|
||||
title="My Protocol"
|
||||
subtitle="Scan to pair your wallet"
|
||||
logoUrl="/my-logo.png"
|
||||
theme={{
|
||||
dialogBackground: "#0f172a",
|
||||
headerBackground: "#0f172a",
|
||||
borderColor: "#334155",
|
||||
}}
|
||||
onCopy={(uri) => {
|
||||
navigator.clipboard.writeText(uri);
|
||||
showToast("Copied!");
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
The `onCopy` callback replaces the default `navigator.clipboard.writeText` behavior, which is
|
||||
useful when the dapp has its own toast/notification system.
|
||||
|
||||
## AlphanumericQRCode
|
||||
|
||||
A standalone canvas-based QR code component. Uses QR Alphanumeric mode with error correction
|
||||
level H (30% recovery), which allows a center logo overlay without breaking the code.
|
||||
|
||||
```tsx
|
||||
<AlphanumericQRCode
|
||||
value="WIZ://..."
|
||||
size={280}
|
||||
foreground="#1e2a4a"
|
||||
background="#ffffff"
|
||||
logoUrl="/logo.png"
|
||||
/>
|
||||
```
|
||||
|
||||
## Session persistence
|
||||
|
||||
By default, `useWizardConnect` persists session data to localStorage under the
|
||||
`wizardconnect-session` key. The stored session includes:
|
||||
|
||||
- **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`
|
||||
|
||||
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-session", // custom storage key (default: "wizardconnect-session")
|
||||
persistSession: false, // disable persistence entirely
|
||||
storage: myCustomStorage, // custom SessionStorage backend (default: localStorage)
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# Relay serialization
|
||||
|
||||
The WizardConnect relay transmits messages as JSON, which cannot represent `BigInt` or
|
||||
`Uint8Array` natively. `@wizardconnect/core` provides canonical encoding helpers so dapps
|
||||
and wallets always agree on the wire format.
|
||||
|
||||
The helpers are split into two layers:
|
||||
|
||||
- **Generic** (`serialize.ts`) — relay-level type coercion (`toUint8Array`, `toBigInt`, `parseExtendedJson`). Useful for any protocol.
|
||||
- **hdwalletv1** (`protocols/hdwalletv1-serialize.ts`) — transaction-specific serialization (`sourceOutputToRelay`, `transactionToHex`). Tied to the sign-transaction flow.
|
||||
|
||||
## Encoding conventions
|
||||
|
||||
| Native type | Relay format | Example |
|
||||
|---------------|-------------------------------------|---------------------------------|
|
||||
| `Uint8Array` | hex string | `"76a914...88ac"` |
|
||||
| `Uint8Array` | extended format (libauth stringify) | `"<Uint8Array: 0x76a914...>"` |
|
||||
| `BigInt` | extended format | `"<bigint: 200000n>"` |
|
||||
|
||||
Both extended formats are accepted by the deserialization helpers. The serialization helpers
|
||||
produce hex strings for `Uint8Array` and `<bigint: Xn>` for `BigInt`.
|
||||
|
||||
## hdwalletv1 serialization (dapp → relay)
|
||||
|
||||
### `sourceOutputToRelay(sourceOutput)`
|
||||
|
||||
Converts a source output with native types to relay-safe JSON:
|
||||
|
||||
```typescript
|
||||
import { sourceOutputToRelay } from "@wizardconnect/core/hdwalletv1-serialize";
|
||||
|
||||
const relayOutput = sourceOutputToRelay({
|
||||
outpointTransactionHash: txidBytes, // Uint8Array → hex string
|
||||
outpointIndex: 0, // number (unchanged)
|
||||
unlockingBytecode: new Uint8Array(0), // Uint8Array → hex string
|
||||
sequenceNumber: 0xffffffff, // number (unchanged)
|
||||
valueSatoshis: 200000n, // BigInt → "<bigint: 200000n>"
|
||||
lockingBytecode: scriptBytes, // Uint8Array → hex string
|
||||
token: { // optional
|
||||
category: categoryBytes, // Uint8Array → hex string
|
||||
amount: 1000n, // BigInt → "<bigint: 1000n>"
|
||||
},
|
||||
});
|
||||
|
||||
// relayOutput is JSON-serializable (no BigInt, no Uint8Array)
|
||||
JSON.stringify(relayOutput); // works
|
||||
```
|
||||
|
||||
### `transactionToHex(inputs, outputs, version?, locktime?)`
|
||||
|
||||
Encodes a transaction to hex using libauth's `encodeTransaction`:
|
||||
|
||||
```typescript
|
||||
import { transactionToHex } from "@wizardconnect/core/hdwalletv1-serialize";
|
||||
|
||||
const txHex = transactionToHex(inputs, outputs);
|
||||
// txHex is a hex string ready for the relay
|
||||
```
|
||||
|
||||
Note: libauth's `encodeTransaction` reverses `outpointTransactionHash` to wire format
|
||||
internally. Pass txids in **display order** (big-endian, as returned by electrum/explorers).
|
||||
|
||||
## Deserialization (relay → wallet)
|
||||
|
||||
### `toUint8Array(value)`
|
||||
|
||||
Converts hex strings, extended JSON format, or `Uint8Array` to `Uint8Array`:
|
||||
|
||||
```typescript
|
||||
import { toUint8Array } from "@wizardconnect/core";
|
||||
|
||||
toUint8Array("76a914...88ac"); // hex string
|
||||
toUint8Array("<Uint8Array: 0x76a914...88ac>"); // extended format
|
||||
toUint8Array(existingBytes); // pass-through
|
||||
```
|
||||
|
||||
### `toBigInt(value)`
|
||||
|
||||
Converts numeric strings, extended JSON format, numbers, or `bigint` to `bigint`:
|
||||
|
||||
```typescript
|
||||
import { toBigInt } from "@wizardconnect/core";
|
||||
|
||||
toBigInt("<bigint: 200000n>"); // extended format
|
||||
toBigInt("200000"); // numeric string
|
||||
toBigInt(200000); // number
|
||||
toBigInt(200000n); // pass-through
|
||||
```
|
||||
|
||||
### `parseExtendedJson(jsonString)`
|
||||
|
||||
Parses a full JSON string, converting all extended-format values in one pass:
|
||||
|
||||
```typescript
|
||||
import { parseExtendedJson } from "@wizardconnect/core";
|
||||
|
||||
const obj = parseExtendedJson('{"value":"<bigint: 200000n>","data":"<Uint8Array: 0xab>"}');
|
||||
// obj.value === 200000n
|
||||
// obj.data instanceof Uint8Array
|
||||
```
|
||||
|
||||
### `isExtendedJsonFormat(str)`
|
||||
|
||||
Returns `true` if a string contains `<bigint: ...>` or `<Uint8Array: ...>` markers.
|
||||
|
||||
## Usage in sign requests
|
||||
|
||||
A typical dapp builds a sign request like this:
|
||||
|
||||
```typescript
|
||||
import { RelayMsgAction } from "@wizardconnect/core";
|
||||
import { sourceOutputToRelay, transactionToHex } from "@wizardconnect/core/hdwalletv1-serialize";
|
||||
|
||||
const txHex = transactionToHex(inputs, outputs);
|
||||
const sourceOutputs = inputs.map((input, i) =>
|
||||
sourceOutputToRelay({
|
||||
...input,
|
||||
valueSatoshis: utxos[i].value,
|
||||
lockingBytecode: utxos[i].script,
|
||||
})
|
||||
);
|
||||
|
||||
const signReq = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
sequence: manager.nextSequence(),
|
||||
transaction: { transaction: txHex, sourceOutputs, broadcast: false },
|
||||
inputPaths: [[0, "receive", 0]],
|
||||
};
|
||||
```
|
||||
|
||||
The wallet deserializes using `toUint8Array` and `toBigInt` on the received fields.
|
||||
|
|
@ -36,7 +36,7 @@ Application message handler
|
|||
|
||||
```typescript
|
||||
new RelayClient({
|
||||
explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.riften.net:443"]
|
||||
explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.cauldron.quest:443"]
|
||||
signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity)
|
||||
pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange)
|
||||
logNetworkActivity?: boolean; // default true
|
||||
|
|
@ -50,12 +50,10 @@ connect(): Promise<void>
|
|||
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
|
||||
|
||||
disconnect(): Promise<void>
|
||||
// Stop subscription, close the relay pool, mark queue not-ready, update
|
||||
// lastProcessedTimestamp. Kills any in-flight publish — see below.
|
||||
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
|
||||
|
||||
relay(message: ProtocolMessage): Promise<void>
|
||||
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
|
||||
// Resolves only once the publish has settled against every configured relay.
|
||||
|
||||
setPairedPublicKey(key: Uint8Array): void
|
||||
// Called after key exchange. Enables outbound messages and incoming peer filtering.
|
||||
|
|
@ -80,10 +78,6 @@ regardless (avoiding silent message loss on slow connections).
|
|||
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
|
||||
held rather than dropped.
|
||||
|
||||
Because `disconnect()` closes the pool, it kills any publish still in flight — so anything
|
||||
sending a final message before tearing down must await the `relay()` first. See
|
||||
[wallet.md § Sending disconnect](wallet.md#sending-disconnect).
|
||||
|
||||
### Replay protection
|
||||
|
||||
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
|
||||
|
|
@ -91,23 +85,6 @@ to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimes
|
|||
dropped. This prevents the relay from re-delivering messages that were already handled before a
|
||||
disconnect.
|
||||
|
||||
### Keepalive
|
||||
|
||||
`RelayClient` creates its `SimplePool` with `enablePing: true`. This enables nostr-tools'
|
||||
built-in heartbeat: every 29 seconds the pool pings each connected relay and expects a
|
||||
response within 20 seconds. In Node.js this uses native WebSocket ping/pong frames; in
|
||||
browsers (where the WebSocket API doesn't expose ping) it falls back to sending a dummy
|
||||
subscription request and waiting for EOSE.
|
||||
|
||||
If a relay fails to respond, nostr-tools closes the WebSocket, which fires the subscription
|
||||
`onclose` callback, which emits `"disconnect"` on `RelayClient`, which triggers the
|
||||
connection manager's existing reconnect loop. This detects "zombie" TCP connections where the
|
||||
socket appears open but the relay is unreachable.
|
||||
|
||||
Additionally, if a `publishMessage()` call fails (all relays reject the event),
|
||||
`RelayClient` emits `"disconnect"` alongside the thrown error. This ensures the reconnect
|
||||
loop starts immediately rather than waiting for the next ping cycle.
|
||||
|
||||
### Sequence numbers
|
||||
|
||||
`nextSequence()` starts at a random offset in the safe integer range and increments by 2. This
|
||||
|
|
@ -205,159 +182,12 @@ the same sender.
|
|||
|
||||
NDK handles all three layers in `giftWrap()` / `giftUnwrap()`.
|
||||
|
||||
## Default relays
|
||||
## Default relay
|
||||
|
||||
```
|
||||
wss://relay.riften.net:443 (primary)
|
||||
wss://relay.cauldron.quest:443 (secondary)
|
||||
wss://relay.cauldron.quest:443
|
||||
```
|
||||
|
||||
Both relays are used by default on both dapp and wallet sides for redundancy. Since Nostr
|
||||
relays do not federate (they don't forward events to each other), connecting to multiple relays
|
||||
ensures messages are delivered even if one relay is temporarily unavailable.
|
||||
|
||||
The connection URI encodes only the primary relay; the secondary is added programmatically
|
||||
by the library. When a custom relay is specified (via URI or `explicitRelayUrls`), only that
|
||||
relay is used — default relays are not auto-added.
|
||||
|
||||
Nothing in the protocol prevents using any other standard Nostr relay.
|
||||
|
||||
### Duplicate message handling
|
||||
|
||||
When subscribed to multiple relays, the same event may arrive from more than one relay.
|
||||
nostr-tools `SimplePool.subscribeMany()` deduplicates events by ID — it tracks seen event IDs
|
||||
in a per-subscription `_knownIds` set and only fires `onevent` once per unique ID. Since
|
||||
`pool.publish(urls, event)` sends the identical event (same ID) to all relays, the receiving
|
||||
side's pool delivers it exactly once.
|
||||
|
||||
## Transport-level extensions
|
||||
|
||||
Transport-level extensions are capabilities of the relay/gift-wrap transport itself, independent
|
||||
of any application protocol. They're distinct from the protocol-level extensions documented in
|
||||
[extensions.md](extensions.md), which extend `hdwalletv1` specifically.
|
||||
|
||||
### Negotiation
|
||||
|
||||
Both sides advertise transport-level extensions in a base `extensions` field on their handshake
|
||||
message (`dapp_ready` / `wallet_ready`). The shape is identical on both sides:
|
||||
|
||||
```typescript
|
||||
interface DappReadyMessage {
|
||||
// ...
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WalletReadyMessage {
|
||||
// ...
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
A capability is considered enabled only when **both** sides advertise it. `RelayClient` exposes
|
||||
`setPeerCapabilities({...})` so the connection managers can inform it once they've parsed the
|
||||
peer's `_ready` message.
|
||||
|
||||
Extension values are per-extension; today `chunk` uses `{ version: 1 }`. Unknown extension keys
|
||||
are ignored by implementations that don't recognise them, so adding new capabilities is always
|
||||
backward-compatible — an old peer simply doesn't advertise them and the new side falls back to
|
||||
the non-extended behaviour.
|
||||
|
||||
### Known transport extensions
|
||||
|
||||
| Extension | Purpose |
|
||||
|---|---|
|
||||
| `chunk` | Split messages larger than NIP-44's 65,535-byte plaintext ceiling across multiple gift-wrapped events. Symmetric — enabled when both sides advertise. See below. |
|
||||
|
||||
## Chunking (`chunk` extension)
|
||||
|
||||
### Why it exists
|
||||
|
||||
NIP-44 caps plaintext at 65,535 bytes — the length is encoded as a U16BE prefix in the wire
|
||||
format, so the limit is structural, not a guardrail that can be raised. NIP-17 gift-wrap
|
||||
additionally encrypts twice (rumor inside seal inside wrap), so a single 50+ KB `ProtocolMessage`
|
||||
will blow the outer wrap's plaintext budget even when the inner payload itself looks small enough.
|
||||
|
||||
Real scenarios that exceed the cap:
|
||||
|
||||
- Aggregated swap `sign_transaction_request` with many pool UTXOs (each with hex `lockingBytecode`
|
||||
and `unlockingBytecode` in the per-input `sourceOutputs`).
|
||||
- `sign_transaction_response` carrying a signed transaction hex string. Policy-max BCH transactions
|
||||
are 100 KB (→ 200 KB hex); consensus-max is 1 MB (→ 2 MB hex).
|
||||
|
||||
### Wire format
|
||||
|
||||
```typescript
|
||||
interface ChunkMessage extends ProtocolMessage {
|
||||
action: "chunk";
|
||||
time: number; // shared across all chunks of one logical message
|
||||
msgId: string; // random identifier, shared across all chunks
|
||||
index: number; // 0-based chunk index within [0, total)
|
||||
total: number; // total number of chunks for this msgId
|
||||
data: string; // base64 slice of the UTF-8 bytes of JSON.stringify(originalMessage)
|
||||
}
|
||||
```
|
||||
|
||||
To reconstruct the original message: concatenate the `data` strings in `index` order, base64-decode
|
||||
to bytes, UTF-8-decode to a string, `JSON.parse`.
|
||||
|
||||
### Sender
|
||||
|
||||
`RelayClient.publishMessage` measures the UTF-8 byte length of the serialized `ProtocolMessage`.
|
||||
If it exceeds the per-message threshold:
|
||||
|
||||
- If the peer's `chunk` capability is enabled, the message is split into `ChunkMessage`s and each
|
||||
is published individually via the same `wrapEvent` path as any other message. No ACKs — see
|
||||
"Failure modes" below.
|
||||
- If the peer has not advertised `chunk`, `publishMessage` throws a structured error
|
||||
(`"Cannot send <action>: message is larger than NIP-44's 65,535-byte ceiling and the peer does
|
||||
not advertise the 'chunk' transport extension. Please update the connected wallet/dapp..."`).
|
||||
This replaces the cryptic `invalid plaintext size` error from nostr-tools.
|
||||
|
||||
The per-chunk budget is sized conservatively. Each chunk's raw data is ≤ 30,000 bytes, which after
|
||||
base64 expansion (~4/3×), JSON envelope overhead, and the two-layer NIP-17 gift-wrap encryption
|
||||
stays well under NIP-44's 65,535-byte ceiling for the outer wrap's plaintext. See
|
||||
`packages/core/src/transforms/chunk.ts` for the derivation.
|
||||
|
||||
### Receiver
|
||||
|
||||
`RelayClient` owns a `ChunkReassembler` instance. Chunks pass the same peer filter and
|
||||
`lastProcessedTimestamp` dedup as any other message, then are routed to the reassembler. When all
|
||||
chunks for a `msgId` have arrived (in any order), the bytes are concatenated, decoded, and the
|
||||
resulting `ProtocolMessage` is handed to the application-level handler — indistinguishable to
|
||||
the application from an unchunked message of equivalent size.
|
||||
|
||||
The reassembler holds two maps with TTL eviction:
|
||||
|
||||
- **in-flight buffers** keyed by `msgId` — collects chunks until complete; default TTL 120 s,
|
||||
sized to comfortably fit a ~35-chunk 2 MB response under real relay latency.
|
||||
- **completed** — tracks `msgId`s we've already delivered, for the same TTL, so late-arriving
|
||||
duplicates (e.g. cross-subscription replay after reconnect) don't spawn a second reassembly and
|
||||
double-deliver.
|
||||
|
||||
A background sweeper runs every 10 s while connected and evicts expired entries. Reassembly state
|
||||
is not persisted — reconnects rely on the relay replaying events to complete any in-flight
|
||||
transfer (see below).
|
||||
|
||||
### Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| Dapp reloads mid-send | Dapp on reload has no in-flight state. User retries → fresh `msgId`, all chunks re-sent. Wallet's partial buffer for the old `msgId` expires via TTL. |
|
||||
| Wallet reloads mid-receive | Subscription filter has no `since` clause, so on reconnect the relay re-delivers all events addressed to the wallet. Chunks reassemble fresh. Works as long as the chunks are still within the relay's retention window. |
|
||||
| Network blip / all relays reject a chunk | `Promise.allSettled` on publish treats each chunk identically to any other single message. If all relays reject, `publishMessage` throws and `emitDisconnect` fires — same posture as today's sign-request failure. |
|
||||
| Relay prunes mid-reassembly | Receiver's partial times out via TTL. Application sees no response; user retries — same failure mode the protocol already has for any lost sign request. |
|
||||
|
||||
No persistence layer, no per-chunk ACKs, no new round trips. The assembled `ProtocolMessage`'s
|
||||
own response (e.g. `sign_transaction_response`) is the effective end-to-end ACK.
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
| Dapp | Wallet | Outcome |
|
||||
|---|---|---|
|
||||
| Old | Old | Unchanged. Oversized message fails at nostr-tools with the raw NIP-44 error. |
|
||||
| **New** | Old | Dapp detects absent `chunk` capability and throws a clear upgrade-guidance error instead of the cryptic NIP-44 error. |
|
||||
| Old | **New** | Wallet detects absent `chunk` capability and throws a clear error symmetrically. |
|
||||
| **New** | **New** | Both advertise, both enable, oversized messages chunk-and-reassemble transparently. Application code is unchanged. |
|
||||
|
||||
Upgrading the library on both sides is sufficient — no adapter-interface changes, no new
|
||||
configuration, no capability opt-in. The extension is always-on when supported.
|
||||
This is a Cauldron-operated Nostr relay. Nothing in the protocol prevents using any other
|
||||
standard Nostr relay. The relay is specified in the connection URI, so wallet and dapp always
|
||||
use the same relay without out-of-band coordination.
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@ interface WalletAdapter {
|
|||
/** Sign the transaction. May show approval UI to the user.
|
||||
* Called when the wallet has received and validated a sign_transaction_request. */
|
||||
signTransaction(request: SignTransactionRequest): Promise<SignTransactionResult>;
|
||||
|
||||
/** Optional: additional paths to include in the session (e.g. stealth_scan). */
|
||||
getAdditionalPaths?(): PathXpub[];
|
||||
|
||||
/** Optional: extension data for the session handshake. */
|
||||
getExtensions?(): Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -69,11 +63,9 @@ class WalletConnectionManager extends EventEmitter {
|
|||
connect(uri: string): string
|
||||
|
||||
// Tear down a specific connection, sending a UserDisconnect courtesy message.
|
||||
// Returns as soon as the connection has left the registry; the relay socket
|
||||
// closes once the courtesy message is published. See § Sending disconnect.
|
||||
disconnect(connectionId: string): void
|
||||
|
||||
// Tear down all connections. Each is disconnected independently.
|
||||
// Tear down all connections.
|
||||
disconnectAll(): void
|
||||
|
||||
// Snapshot of all connections for UI rendering.
|
||||
|
|
@ -90,7 +82,6 @@ class WalletConnectionManager extends EventEmitter {
|
|||
on("pendingSignRequest", (req: PendingSignRequest) => void)
|
||||
on("connectionsChanged", () => void)
|
||||
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
|
||||
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -138,9 +129,7 @@ interface PendingSignRequest {
|
|||
Sends `wallet_ready` with:
|
||||
- `supported_protocols: ["hdwalletv1"]`
|
||||
- `wallet_name`, `wallet_icon` from the adapter.
|
||||
- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi),
|
||||
plus any additional paths from `adapter.getAdditionalPaths()` and extension data from
|
||||
`adapter.getExtensions()`. See [extensions.md](extensions.md).
|
||||
- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi).
|
||||
- `dapp_discovered`: whether the dapp was seen in this runtime session.
|
||||
|
||||
The message is pushed to a per-connection `notificationQueue` and flushed immediately. Retry
|
||||
|
|
@ -156,28 +145,6 @@ wallet_discovered=true → set dappDiscovered=true, no further action
|
|||
|
||||
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
|
||||
|
||||
### Sending disconnect
|
||||
|
||||
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
|
||||
have opposite timing requirements.
|
||||
|
||||
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
|
||||
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
|
||||
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
|
||||
connection left in the map during teardown would be handed back to a caller as if it were live.
|
||||
|
||||
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
|
||||
published. `RelayClient.relay()` resolves after the publish settles against every configured
|
||||
relay, which is a real round trip; closing the socket before that kills the publish in flight and
|
||||
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
|
||||
|
||||
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
|
||||
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
|
||||
courtesy message that is never delivered.
|
||||
|
||||
Callers do not need to await anything. The observable contract is that state is correct
|
||||
immediately and delivery is best-effort within the timeout.
|
||||
|
||||
### Receiving disconnect
|
||||
|
||||
When a `disconnect` message arrives from the dapp:
|
||||
|
|
@ -196,19 +163,6 @@ application is responsible for:
|
|||
|
||||
The wallet library does not auto-sign or auto-reject anything.
|
||||
|
||||
**Deduplication:** The dapp re-sends pending sign requests when the wallet reconnects (see
|
||||
[protocol docs](protocol.md#re-delivery-on-reconnect)). To prevent duplicate approval dialogs,
|
||||
`WalletConnectionManager` tracks in-flight sequences and silently drops requests whose `sequence`
|
||||
has already been emitted. The guard is cleared when a response is sent (`sendSignResponse` /
|
||||
`sendSignError`) or a `sign_cancel` is received.
|
||||
|
||||
**SIGHASH enforcement:** The wallet **MUST** sign every input with
|
||||
`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
|
||||
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol
|
||||
docs for the security rationale. Because the dapp specifies `inputPaths`, the wallet trusts the
|
||||
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
|
||||
invalid and cannot be repurposed).
|
||||
|
||||
## Minimal example
|
||||
|
||||
```typescript
|
||||
|
|
@ -224,8 +178,7 @@ class MyAdapter implements WalletAdapter {
|
|||
getXpub(path: DerivationPath) { /* ... */ }
|
||||
|
||||
async signTransaction(request) {
|
||||
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
|
||||
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
|
||||
// show approval UI, sign, return hex
|
||||
return { signedTransactionHex: "..." };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
# xpub sharing: why it's safe and why we do it
|
||||
|
||||
When people hear "xpub sharing" they think of handing over the account-level
|
||||
xpub — the master key that can derive every address in a wallet. That's not
|
||||
what WizardConnect does. This page explains what we actually share, why it's
|
||||
safe, and why the alternatives are worse.
|
||||
|
||||
## What we share
|
||||
|
||||
A BIP44 HD wallet has this tree structure:
|
||||
|
||||
```
|
||||
m / 44' / 145' / 0' ← account xpub (we do NOT share this)
|
||||
/ 0 ← receive chain xpub (we share this)
|
||||
/ 0 ← address 0
|
||||
/ 1 ← address 1
|
||||
/ 2 ← address 2
|
||||
/ ...
|
||||
/ 1 ← change chain xpub (we share this)
|
||||
/ 0
|
||||
/ 1
|
||||
/ ...
|
||||
```
|
||||
|
||||
WizardConnect shares xpubs **one level below the account** — at the chain
|
||||
level. The dapp gets the receive chain xpub and can derive receive addresses.
|
||||
It cannot derive change addresses, and it cannot derive the account xpub.
|
||||
|
||||
This is the same level of key material that watch-only wallets, block
|
||||
explorers, and payment processors have always worked with. Electron Cash's
|
||||
"watching-only wallet" import works at exactly this level.
|
||||
|
||||
## What about privacy?
|
||||
|
||||
Sharing a chain xpub gives the dapp the ability to derive all addresses on
|
||||
that chain. This means a dapp you connect to can see your receive addresses
|
||||
and monitor incoming payments.
|
||||
|
||||
This is by design — the dapp needs addresses to construct transactions. The
|
||||
question is how you deliver them.
|
||||
|
||||
### The three options
|
||||
|
||||
| Approach | Round trips | Privacy | UX |
|
||||
|----------|-------------|---------|-----|
|
||||
| Individual pubkeys | One per address | Best — dapp sees only what it asks for | Terrible on mobile — constant app switching |
|
||||
| Chain xpub (WizardConnect) | Zero after handshake | Good — dapp sees one chain, not the whole wallet | Seamless — scan once, done |
|
||||
| Account xpub | Zero after handshake | Poor — dapp sees everything | Seamless |
|
||||
|
||||
We started with individual pubkeys. On desktop it was tolerable. On mobile it
|
||||
was unusable — every new address required switching to the wallet app,
|
||||
approving, switching back. For a transaction with two inputs and a change
|
||||
output, that's three round trips with three app switches. Users gave up.
|
||||
|
||||
Chain xpubs eliminate all round trips while limiting exposure to a single
|
||||
purpose. A dapp connected for receive addresses cannot see your change
|
||||
addresses or any other internal wallet activity.
|
||||
|
||||
### Per-session xpubs (advanced)
|
||||
|
||||
A wallet that wants maximum privacy can derive xpubs from non-standard paths
|
||||
and rotate them each session:
|
||||
|
||||
```
|
||||
Session 1: receive xpub from m/44'/145'/0'/1000/0
|
||||
Session 2: receive xpub from m/44'/145'/0'/1001/0
|
||||
```
|
||||
|
||||
The protocol carries only the xpub and a name ("receive") — the dapp never
|
||||
learns the derivation path. Each session gets a fresh, isolated set of
|
||||
addresses. The trade-off is that standard wallet recovery (which scans only
|
||||
the BIP44 paths) won't find funds at these paths without extra metadata.
|
||||
|
||||
## Is this the same as sharing your full xpub?
|
||||
|
||||
No. The account xpub (`m/44'/145'/0'`) can derive keys for *all* chains —
|
||||
receive, change, and any application-specific paths. Sharing it gives a dapp
|
||||
complete visibility into your wallet's transaction graph, including internal
|
||||
change outputs.
|
||||
|
||||
A chain xpub can only derive addresses on its own chain. It's one branch of
|
||||
the tree, not the trunk.
|
||||
|
||||
```
|
||||
Account xpub → can derive receive + change + defi + everything else
|
||||
(this is what people worry about)
|
||||
|
||||
Receive chain xpub → can derive receive addresses only
|
||||
(this is what we share)
|
||||
```
|
||||
|
||||
## The security question: "can someone steal my funds?"
|
||||
|
||||
An xpub contains only public keys. Public keys cannot sign transactions.
|
||||
Sharing an xpub — at any level — does not give anyone the ability to move
|
||||
funds.
|
||||
|
||||
The theoretical concern with BIP32 unhardened derivation is: if an attacker
|
||||
gets a child *private* key **and** the parent xpub, they can compute the
|
||||
parent private key. But in WizardConnect, private keys never leave the wallet.
|
||||
Signing happens on the wallet device and only the signed transaction is
|
||||
returned. The attack requires compromising the wallet itself, at which point
|
||||
the attacker already has the keys.
|
||||
|
||||
This is the same security model as every BIP44 wallet in existence. Electron
|
||||
Cash, Bitcoin Core (with descriptors), and every hardware wallet that supports
|
||||
watch-only mode all rely on the same property: xpub sharing is safe as long
|
||||
as private keys stay private.
|
||||
|
||||
## Comparison with other protocols
|
||||
|
||||
**Electrum protocol**: shares individual pubkeys on demand. Maximum privacy,
|
||||
but requires the wallet to be online for every new address. Poor mobile UX.
|
||||
|
||||
**WalletConnect v2 (Ethereum)**: shares account addresses, not xpubs. Works
|
||||
for Ethereum's account model where one address is reused. Does not apply to
|
||||
UTXO chains like BCH where each transaction should use a fresh address.
|
||||
|
||||
**BIP47 payment codes**: enables reusable payment addresses between two
|
||||
parties using a shared secret derived from both parties' xpubs. Solves a
|
||||
different problem (sender-receiver privacy for recurring payments) and
|
||||
requires an on-chain notification transaction.
|
||||
|
||||
**WizardConnect**: shares chain-level xpubs by name. Zero round trips, chain
|
||||
isolation, optional per-session rotation. Designed specifically for the UTXO
|
||||
model where dapps need to derive many addresses.
|
||||
|
||||
## Summary
|
||||
|
||||
- We share chain xpubs, not account xpubs. A dapp sees one branch, not the
|
||||
whole tree.
|
||||
- Chain xpubs contain only public keys. They cannot sign transactions or move
|
||||
funds.
|
||||
- The security model is identical to watch-only wallets, which have been
|
||||
standard practice in Bitcoin for over a decade.
|
||||
- The alternative (individual pubkeys) was tried and produced an unusable
|
||||
mobile experience.
|
||||
- Wallets that want stronger privacy can rotate xpubs per session — the
|
||||
protocol supports this without changes.
|
||||
|
|
@ -8,7 +8,7 @@ module.exports = [
|
|||
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser,
|
||||
parserOptions: {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const REQUIRED_LINES = [
|
|||
// Matches: "Copyright (C) 2026 Whiterun LLC" or "Copyright (C) 2024-2026 Whiterun LLC"
|
||||
const COPYRIGHT_PATTERN = /Copyright \(C\) (\d{4}-)?\d{4} Whiterun LLC/;
|
||||
|
||||
const EXTENSIONS = [".ts", ".tsx", ".mjs"];
|
||||
const EXTENSIONS = [".ts", ".mjs"];
|
||||
const DIRECTORIES = ["packages", "linters"];
|
||||
|
||||
function walk(dir) {
|
||||
|
|
|
|||
1062
package-lock.json
generated
1062
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@
|
|||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/react -w packages/test-cli",
|
||||
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/test-cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test:integration": "npm run test:integration --workspaces --if-present",
|
||||
"dapp": "npm run dapp --workspace @wizardconnect/test-cli",
|
||||
|
|
|
|||
|
|
@ -1,23 +1,8 @@
|
|||
{
|
||||
"name": "@wizardconnect/core",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"description": "Transport and protocol primitives for WizardConnect",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitlab.com/riftenlabs/lib/wizardconnect",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./hdwalletv1-serialize": {
|
||||
"types": "./dist/protocols/hdwalletv1-serialize.d.ts",
|
||||
"default": "./dist/protocols/hdwalletv1-serialize.js"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
|
|
@ -35,15 +20,15 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@bch-wc2/interfaces": "^0.0.8",
|
||||
"nostr-tools": "^2.23.0",
|
||||
"@nostr-dev-kit/ndk": "^2.18.1",
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import {
|
|||
generateKeyExchangeCredentials,
|
||||
encodeKeyExchangeURI,
|
||||
KeyExchangeCredentials,
|
||||
DEFAULT_RELAY_URLS,
|
||||
DEFAULT_RELAY_HOSTNAME,
|
||||
DEFAULT_RELAY_PORT,
|
||||
DEFAULT_RELAY_PROTOCOL,
|
||||
} from "./key-exchange.js";
|
||||
import { hexToBin, binToHex } from "@bitauth/libauth";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
|
@ -28,7 +30,6 @@ export interface DappRelayOptions {
|
|||
existingCredentials?: {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
walletPublicKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -103,13 +104,6 @@ export function initiateDappRelay(
|
|||
let keyExchanged = false;
|
||||
let walletPublicKeyNostr: Uint8Array | null = null;
|
||||
|
||||
if (options.existingCredentials) {
|
||||
walletPublicKeyNostr = hexToBin(
|
||||
options.existingCredentials.walletPublicKey,
|
||||
);
|
||||
keyExchanged = true;
|
||||
}
|
||||
|
||||
const wrappedCallback: RelayStatusCallback = (
|
||||
payload: RelayUpdatePayload,
|
||||
) => {
|
||||
|
|
@ -169,12 +163,14 @@ export function initiateDappRelay(
|
|||
const relayUrls =
|
||||
options.explicitRelayUrls && options.explicitRelayUrls.length > 0
|
||||
? options.explicitRelayUrls
|
||||
: [...DEFAULT_RELAY_URLS];
|
||||
: [
|
||||
`${DEFAULT_RELAY_PROTOCOL}://${DEFAULT_RELAY_HOSTNAME}:${DEFAULT_RELAY_PORT}`,
|
||||
];
|
||||
|
||||
const cleanup = initiateRelay(
|
||||
wrappedCallback,
|
||||
dappPrivateKey,
|
||||
walletPublicKeyNostr ?? new Uint8Array(33),
|
||||
new Uint8Array(33),
|
||||
{
|
||||
explicitRelayUrls: relayUrls,
|
||||
reconnectInterval: options.reconnectInterval,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
export { RelayClient } from "./relay-client.js";
|
||||
export type { RelayClientConfig } from "./relay-client.js";
|
||||
export { SimplePool } from "nostr-tools/pool";
|
||||
export { RelayStatus, initiateRelay } from "./relay-handler.js";
|
||||
export type {
|
||||
RelayUpdatePayload,
|
||||
|
|
@ -21,7 +20,6 @@ export {
|
|||
DEFAULT_RELAY_HOSTNAME,
|
||||
DEFAULT_RELAY_PORT,
|
||||
DEFAULT_RELAY_PROTOCOL,
|
||||
DEFAULT_RELAY_URLS,
|
||||
} from "./key-exchange.js";
|
||||
export type {
|
||||
KeyExchangeCredentials,
|
||||
|
|
@ -31,22 +29,9 @@ export type {
|
|||
} from "./key-exchange.js";
|
||||
export * from "./protocols/hdwalletv1.js";
|
||||
export * from "./protocols/base.js";
|
||||
export {
|
||||
CHUNK_EXTENSION_NAME,
|
||||
CHUNK_EXTENSION_VERSION,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
} from "./transforms/chunk.js";
|
||||
export {
|
||||
binToHex,
|
||||
hexToBin,
|
||||
binToBech32Padded,
|
||||
bech32PaddedToBin,
|
||||
} from "@bitauth/libauth";
|
||||
export {
|
||||
parseExtendedJson,
|
||||
parseExtendedJsonValue,
|
||||
isExtendedJsonFormat,
|
||||
toUint8Array,
|
||||
toBigInt,
|
||||
} from "./serialize.js";
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import {
|
|||
encodeKeyExchangeURI,
|
||||
decodeKeyExchangeURI,
|
||||
generateKeyExchangeCredentials,
|
||||
DEFAULT_RELAY_URLS,
|
||||
DEFAULT_RELAY_HOSTNAME,
|
||||
} from "./key-exchange.js";
|
||||
|
||||
describe("key-exchange", () => {
|
||||
|
|
@ -202,7 +200,7 @@ describe("key-exchange", () => {
|
|||
});
|
||||
|
||||
it("should throw error for wrong scheme", () => {
|
||||
const invalidUri = "wrong://relay.riften.net?p=abc&s=def";
|
||||
const invalidUri = "wrong://relay.cauldron.quest?p=abc&s=def";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
|
||||
"Invalid URI scheme",
|
||||
|
|
@ -227,7 +225,7 @@ describe("key-exchange", () => {
|
|||
});
|
||||
|
||||
it("should throw error for missing parameters", () => {
|
||||
const invalidUri = "wiz://relay.riften.net?p=abc";
|
||||
const invalidUri = "wiz://relay.cauldron.quest?p=abc";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
|
||||
"Invalid URI format",
|
||||
|
|
@ -237,7 +235,7 @@ describe("key-exchange", () => {
|
|||
it("should throw error for invalid bech32 encoding", () => {
|
||||
// Use invalid bech32 characters (bech32 only uses: qpzry9x8gf2tvdw0s3jn54khce6mua7l)
|
||||
// This will fail at bech32 decoding
|
||||
const invalidUri = "wiz://relay.riften.net?p=invalid&s=chars";
|
||||
const invalidUri = "wiz://relay.cauldron.quest?p=invalid&s=chars";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
|
||||
"Invalid bech32 encoding",
|
||||
|
|
@ -252,7 +250,7 @@ describe("key-exchange", () => {
|
|||
"b".repeat(16),
|
||||
);
|
||||
const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || "";
|
||||
const invalidUri = `wiz://relay.riften.net?p=${shortBech32}&s=${secretPart}`;
|
||||
const invalidUri = `wiz://relay.cauldron.quest?p=${shortBech32}&s=${secretPart}`;
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
|
||||
});
|
||||
|
|
@ -264,7 +262,7 @@ describe("key-exchange", () => {
|
|||
);
|
||||
const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || "";
|
||||
const shortBech32 = "q";
|
||||
const invalidUri = `wiz://relay.riften.net?p=${publicKeyPart}&s=${shortBech32}`;
|
||||
const invalidUri = `wiz://relay.cauldron.quest?p=${publicKeyPart}&s=${shortBech32}`;
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
|
||||
});
|
||||
|
|
@ -373,18 +371,4 @@ describe("key-exchange", () => {
|
|||
expect(decoded.secret).toBe(secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_RELAY_URLS", () => {
|
||||
it("has relay.riften.net as primary (first entry)", () => {
|
||||
expect(DEFAULT_RELAY_URLS[0]).toBe("wss://relay.riften.net:443");
|
||||
});
|
||||
|
||||
it("contains relay.cauldron.quest as secondary", () => {
|
||||
expect(DEFAULT_RELAY_URLS).toContain("wss://relay.cauldron.quest:443");
|
||||
});
|
||||
|
||||
it("primary URL matches DEFAULT_RELAY_HOSTNAME", () => {
|
||||
expect(DEFAULT_RELAY_URLS[0]).toContain(DEFAULT_RELAY_HOSTNAME);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,16 +11,10 @@ import {
|
|||
} from "@bitauth/libauth";
|
||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
|
||||
export const DEFAULT_RELAY_HOSTNAME = "relay.riften.net";
|
||||
export const DEFAULT_RELAY_HOSTNAME = "relay.cauldron.quest";
|
||||
export const DEFAULT_RELAY_PORT = 443;
|
||||
export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss";
|
||||
|
||||
/** All default relay URLs, primary first. */
|
||||
export const DEFAULT_RELAY_URLS: readonly string[] = [
|
||||
"wss://relay.riften.net:443",
|
||||
"wss://relay.cauldron.quest:443",
|
||||
];
|
||||
|
||||
export interface KeyExchangeCredentials {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@ export interface DappReadyMessage extends ProtocolMessage {
|
|||
wallet_discovered: boolean;
|
||||
dapp_name?: string;
|
||||
dapp_icon?: string;
|
||||
/// Transport-level extensions this dapp supports. Presence of a key = support.
|
||||
/// Distinct from Hdwalletv1Session.extensions which is protocol-level.
|
||||
/// See docs/transport.md.
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WalletReadyMessage extends ProtocolMessage {
|
||||
|
|
@ -52,10 +48,6 @@ export interface WalletReadyMessage extends ProtocolMessage {
|
|||
public_key: string;
|
||||
/// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention.
|
||||
secret: string;
|
||||
/// Transport-level extensions this wallet supports. Presence of a key = support.
|
||||
/// Distinct from Hdwalletv1Session.extensions which is protocol-level.
|
||||
/// See docs/transport.md.
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage {
|
||||
|
|
@ -101,11 +93,3 @@ export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage {
|
|||
typeof (msg as DisconnectMessage).reason === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export interface PingMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Ping;
|
||||
}
|
||||
|
||||
export interface PongMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Pong;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
// 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 } from "vitest";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
import {
|
||||
sourceOutputToRelay,
|
||||
transactionToHex,
|
||||
} from "./hdwalletv1-serialize.js";
|
||||
import { toUint8Array, toBigInt } from "../serialize.js";
|
||||
|
||||
describe("hdwalletv1-serialize", () => {
|
||||
describe("sourceOutputToRelay", () => {
|
||||
it("converts native types to relay-safe JSON", () => {
|
||||
const so = {
|
||||
outpointTransactionHash: hexToBin("ab".repeat(32)),
|
||||
outpointIndex: 0,
|
||||
unlockingBytecode: new Uint8Array(0),
|
||||
sequenceNumber: 0xffffffff,
|
||||
valueSatoshis: 200000n,
|
||||
lockingBytecode: hexToBin("76a914" + "00".repeat(20) + "88ac"),
|
||||
};
|
||||
|
||||
const result = sourceOutputToRelay(so);
|
||||
|
||||
expect(result.outpointTransactionHash).toBe("ab".repeat(32));
|
||||
expect(result.outpointIndex).toBe(0);
|
||||
expect(result.unlockingBytecode).toBe("");
|
||||
expect(result.sequenceNumber).toBe(0xffffffff);
|
||||
expect(result.valueSatoshis).toBe("<bigint: 200000n>");
|
||||
expect(result.lockingBytecode).toBe("76a914" + "00".repeat(20) + "88ac");
|
||||
});
|
||||
|
||||
it("is JSON-serializable", () => {
|
||||
const so = {
|
||||
outpointTransactionHash: new Uint8Array(32),
|
||||
outpointIndex: 0,
|
||||
unlockingBytecode: new Uint8Array(0),
|
||||
sequenceNumber: 0xffffffff,
|
||||
valueSatoshis: 100000000n,
|
||||
lockingBytecode: new Uint8Array([0x76, 0xa9]),
|
||||
};
|
||||
|
||||
const result = sourceOutputToRelay(so);
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it("includes token data when present", () => {
|
||||
const so = {
|
||||
outpointTransactionHash: new Uint8Array(32),
|
||||
outpointIndex: 0,
|
||||
unlockingBytecode: new Uint8Array(0),
|
||||
sequenceNumber: 0xffffffff,
|
||||
valueSatoshis: 546n,
|
||||
lockingBytecode: new Uint8Array([0x76, 0xa9]),
|
||||
token: {
|
||||
category: hexToBin("cc".repeat(32)),
|
||||
amount: 1000n,
|
||||
},
|
||||
};
|
||||
|
||||
const result = sourceOutputToRelay(so);
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.token.category).toBe("cc".repeat(32));
|
||||
expect(result.token.amount).toBe("<bigint: 1000n>");
|
||||
});
|
||||
|
||||
it("round-trips through toBigInt/toUint8Array", () => {
|
||||
const so = {
|
||||
outpointTransactionHash: hexToBin("ab".repeat(32)),
|
||||
outpointIndex: 2,
|
||||
unlockingBytecode: new Uint8Array(0),
|
||||
sequenceNumber: 0xffffffff,
|
||||
valueSatoshis: 200000n,
|
||||
lockingBytecode: hexToBin("76a914" + "00".repeat(20) + "88ac"),
|
||||
};
|
||||
|
||||
const relay = sourceOutputToRelay(so);
|
||||
|
||||
// Simulate what the wallet does: parse the relay JSON
|
||||
expect(toUint8Array(relay.outpointTransactionHash)).toEqual(
|
||||
so.outpointTransactionHash,
|
||||
);
|
||||
expect(toBigInt(relay.valueSatoshis)).toBe(so.valueSatoshis);
|
||||
expect(toUint8Array(relay.lockingBytecode)).toEqual(so.lockingBytecode);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transactionToHex", () => {
|
||||
it("encodes a simple transaction", () => {
|
||||
const inputs = [
|
||||
{
|
||||
outpointTransactionHash: new Uint8Array(32),
|
||||
outpointIndex: 0,
|
||||
unlockingBytecode: new Uint8Array(0),
|
||||
sequenceNumber: 0xffffffff,
|
||||
},
|
||||
];
|
||||
const outputs = [
|
||||
{
|
||||
valueSatoshis: 0n,
|
||||
lockingBytecode: new Uint8Array([0x6a]), // OP_RETURN
|
||||
},
|
||||
];
|
||||
|
||||
const hex = transactionToHex(inputs, outputs);
|
||||
expect(typeof hex).toBe("string");
|
||||
expect(hex.length).toBeGreaterThan(0);
|
||||
// Should start with version 2 in little-endian
|
||||
expect(hex.startsWith("02000000")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* Serialization helpers specific to the hdwalletv1 sign-transaction flow.
|
||||
*
|
||||
* These convert between native libauth transaction types and the relay-safe
|
||||
* JSON used in SignTransactionRequest / SignTransactionResponse messages.
|
||||
*/
|
||||
|
||||
import { binToHex, encodeTransaction } from "@bitauth/libauth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interfaces (mirror libauth transaction shapes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SourceOutput {
|
||||
outpointTransactionHash: Uint8Array;
|
||||
outpointIndex: number;
|
||||
unlockingBytecode: Uint8Array;
|
||||
sequenceNumber: number;
|
||||
valueSatoshis: bigint;
|
||||
lockingBytecode: Uint8Array;
|
||||
token?: {
|
||||
category: Uint8Array;
|
||||
amount: bigint;
|
||||
nft?: { capability?: string; commitment?: Uint8Array };
|
||||
};
|
||||
}
|
||||
|
||||
interface TxInput {
|
||||
outpointTransactionHash: Uint8Array;
|
||||
outpointIndex: number;
|
||||
unlockingBytecode: Uint8Array;
|
||||
sequenceNumber: number;
|
||||
}
|
||||
|
||||
interface TxOutput {
|
||||
valueSatoshis: bigint;
|
||||
lockingBytecode: Uint8Array;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization (native types → relay JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a source output to relay-safe JSON format.
|
||||
* Uint8Array fields become hex strings, BigInt becomes `<bigint: Xn>`.
|
||||
*/
|
||||
export function sourceOutputToRelay(so: SourceOutput): any {
|
||||
const result: any = {
|
||||
outpointTransactionHash: binToHex(so.outpointTransactionHash),
|
||||
outpointIndex: so.outpointIndex,
|
||||
unlockingBytecode: binToHex(so.unlockingBytecode),
|
||||
sequenceNumber: so.sequenceNumber,
|
||||
valueSatoshis: `<bigint: ${so.valueSatoshis}n>`,
|
||||
lockingBytecode: binToHex(so.lockingBytecode),
|
||||
};
|
||||
if (so.token) {
|
||||
result.token = {
|
||||
category: binToHex(so.token.category),
|
||||
amount: `<bigint: ${so.token.amount}n>`,
|
||||
...(so.token.nft && {
|
||||
nft: {
|
||||
...(so.token.nft.capability !== undefined && {
|
||||
capability: so.token.nft.capability,
|
||||
}),
|
||||
...(so.token.nft.commitment !== undefined && {
|
||||
commitment: binToHex(so.token.nft.commitment),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a transaction to hex for relay transport.
|
||||
*/
|
||||
export function transactionToHex(
|
||||
inputs: TxInput[],
|
||||
outputs: TxOutput[],
|
||||
version = 2,
|
||||
locktime = 0,
|
||||
): string {
|
||||
return binToHex(encodeTransaction({ inputs, outputs, version, locktime }));
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// 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 } from "vitest";
|
||||
import { isSignTransactionRequest, RelayMsgAction } from "./hdwalletv1.js";
|
||||
|
||||
describe("isSignTransactionRequest", () => {
|
||||
const valid = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
transaction: { transaction: {}, sourceOutputs: [] },
|
||||
sequence: 1,
|
||||
inputPaths: [
|
||||
[0, "receive", 0],
|
||||
[2, "change", 3],
|
||||
],
|
||||
time: 1000,
|
||||
};
|
||||
|
||||
it("accepts valid message with inputPaths", () => {
|
||||
expect(isSignTransactionRequest(valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts empty inputPaths", () => {
|
||||
expect(isSignTransactionRequest({ ...valid, inputPaths: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing inputPaths", () => {
|
||||
const noInputPaths = { ...valid } as Record<string, unknown>;
|
||||
delete noInputPaths.inputPaths;
|
||||
expect(isSignTransactionRequest(noInputPaths)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-array inputPaths", () => {
|
||||
expect(isSignTransactionRequest({ ...valid, inputPaths: "bad" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tuple with wrong types", () => {
|
||||
expect(
|
||||
isSignTransactionRequest({
|
||||
...valid,
|
||||
inputPaths: [["receive", 0, 1]],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects tuple with wrong length", () => {
|
||||
expect(
|
||||
isSignTransactionRequest({ ...valid, inputPaths: [[0, "receive"]] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing action", () => {
|
||||
const noAction = { ...valid } as Record<string, unknown>;
|
||||
delete noAction.action;
|
||||
expect(isSignTransactionRequest(noAction)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing transaction", () => {
|
||||
const noTx = { ...valid } as Record<string, unknown>;
|
||||
delete noTx.transaction;
|
||||
expect(isSignTransactionRequest(noTx)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("rejects missing sequence", () => {
|
||||
const noSeq = { ...valid } as Record<string, unknown>;
|
||||
delete noSeq.sequence;
|
||||
expect(isSignTransactionRequest(noSeq)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,14 +25,6 @@ export enum RelayMsgAction {
|
|||
SignCancel = "sign_cancel",
|
||||
/// Courtesy notification: one side is closing the connection.
|
||||
Disconnect = "disconnect",
|
||||
/// Transport-level: carries one slice of a message that exceeds NIP-44's
|
||||
/// 65,535-byte plaintext ceiling. See ChunkMessage and docs/transport.md.
|
||||
/// Not tied to hdwalletv1 semantics — applies to any application protocol.
|
||||
Chunk = "chunk",
|
||||
/// Keepalive ping sent by the dapp every 10 s to prevent relay silence timeouts.
|
||||
Ping = "ping",
|
||||
/// Keepalive pong sent by the wallet in response to each ping.
|
||||
Pong = "pong",
|
||||
}
|
||||
|
||||
export interface ProtocolMessage {
|
||||
|
|
@ -40,16 +32,10 @@ export interface ProtocolMessage {
|
|||
time: number;
|
||||
}
|
||||
|
||||
/// Well-known path names. PathName is an open string — wallets may include
|
||||
/// additional paths (e.g. "stealth_scan", "stealth_spend", "rpa") via extensions.
|
||||
export const PATH_RECEIVE = "receive" as const;
|
||||
export const PATH_CHANGE = "change" as const;
|
||||
export const PATH_DEFI = "defi" as const;
|
||||
|
||||
export type PathName = string;
|
||||
export type PathName = "receive" | "change" | "defi";
|
||||
|
||||
export interface PathXpub {
|
||||
name: PathName;
|
||||
name: PathName; // "receive" | "change" | "defi"
|
||||
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
|
||||
}
|
||||
|
||||
|
|
@ -62,19 +48,16 @@ export function isPathXpub(obj: any): obj is PathXpub {
|
|||
);
|
||||
}
|
||||
|
||||
/** Returns the numeric BIP44 child index for the given well-known path name.
|
||||
* Returns undefined for extension path names (e.g. "stealth_scan").
|
||||
/** Returns the numeric BIP44 child index for the given named path.
|
||||
* Use only for internal HD derivation — not a protocol concern. */
|
||||
export function childIndexOfPathName(name: PathName): number | undefined {
|
||||
export function childIndexOfPathName(name: PathName): number {
|
||||
switch (name) {
|
||||
case PATH_RECEIVE:
|
||||
case "receive":
|
||||
return 0;
|
||||
case PATH_CHANGE:
|
||||
case "change":
|
||||
return 1;
|
||||
case PATH_DEFI:
|
||||
case "defi":
|
||||
return 7;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,21 +95,14 @@ export function childIndexOfPathName(name: PathName): number | undefined {
|
|||
export interface Hdwalletv1Session {
|
||||
/// BIP32 xpubs for each named derivation path.
|
||||
paths: PathXpub[];
|
||||
/// Optional extension data. Each key is an extension name; its presence
|
||||
/// indicates the wallet supports that extension. The value carries any
|
||||
/// extension-specific handshake data (or {} if none is needed).
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isHdwalletv1Session(obj: unknown): obj is Hdwalletv1Session {
|
||||
const s = obj as Hdwalletv1Session;
|
||||
return (
|
||||
obj !== null &&
|
||||
typeof obj === "object" &&
|
||||
Array.isArray(s.paths) &&
|
||||
s.paths.every((p) => isPathXpub(p)) &&
|
||||
(s.extensions === undefined ||
|
||||
(typeof s.extensions === "object" && s.extensions !== null))
|
||||
Array.isArray((obj as Hdwalletv1Session).paths) &&
|
||||
(obj as Hdwalletv1Session).paths.every((p) => isPathXpub(p))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +114,6 @@ export interface SignTransactionRequest extends ProtocolMessage {
|
|||
action: RelayMsgAction.SignTransactionRequest;
|
||||
transaction: WcSignTransactionRequest;
|
||||
sequence: number;
|
||||
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
|
||||
}
|
||||
|
||||
export interface SignTransactionResponse extends ProtocolMessage {
|
||||
|
|
@ -154,20 +129,6 @@ export interface SignCancelMessage extends ProtocolMessage {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
/// Transport-level message carrying one slice of a larger ProtocolMessage.
|
||||
///
|
||||
/// All chunks of one logical message share the same msgId and time.
|
||||
/// `data` is a base64-encoded slice of the UTF-8 bytes of
|
||||
/// JSON.stringify(originalMessage); concatenate slices in `index` order,
|
||||
/// base64-decode, UTF-8-decode, then JSON.parse to reconstruct.
|
||||
export interface ChunkMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Chunk;
|
||||
msgId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
// Type guard functions
|
||||
|
||||
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
|
||||
|
|
@ -194,16 +155,7 @@ export function isSignTransactionRequest(
|
|||
msg.action === RelayMsgAction.SignTransactionRequest &&
|
||||
msg.transaction &&
|
||||
typeof msg.transaction === "object" &&
|
||||
typeof msg.sequence === "number" &&
|
||||
Array.isArray(msg.inputPaths) &&
|
||||
msg.inputPaths.every(
|
||||
(p: any) =>
|
||||
Array.isArray(p) &&
|
||||
p.length === 3 &&
|
||||
typeof p[0] === "number" &&
|
||||
typeof p[1] === "string" &&
|
||||
typeof p[2] === "number",
|
||||
)
|
||||
typeof msg.sequence === "number"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -215,20 +167,3 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
|
|||
typeof msg.sequence === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isChunkMessage(msg: any): msg is ChunkMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === "object" &&
|
||||
msg.action === RelayMsgAction.Chunk &&
|
||||
typeof msg.msgId === "string" &&
|
||||
typeof msg.index === "number" &&
|
||||
typeof msg.total === "number" &&
|
||||
typeof msg.data === "string" &&
|
||||
Number.isInteger(msg.index) &&
|
||||
Number.isInteger(msg.total) &&
|
||||
msg.total >= 1 &&
|
||||
msg.index >= 0 &&
|
||||
msg.index < msg.total
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,305 +0,0 @@
|
|||
import { describe, it, expect, vi, afterEach, type Mock } from "vitest";
|
||||
import { generateRandomBytes, secp256k1 } from "@bitauth/libauth";
|
||||
|
||||
// Mock nostr-tools and isomorphic-ws before importing RelayClient
|
||||
vi.mock("nostr-tools/nip59", () => ({
|
||||
wrapEvent: vi.fn(() => ({ kind: 1059, content: "wrapped" })),
|
||||
unwrapEvent: vi.fn((_event: any, _key: any) => ({
|
||||
kind: 14,
|
||||
content: '{"action":"dapp_ready","time":9999999999}',
|
||||
pubkey: "aa".repeat(32),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("nostr-tools/pool", () => ({
|
||||
SimplePool: vi.fn(),
|
||||
useWebSocketImplementation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("isomorphic-ws", () => ({ default: vi.fn() }));
|
||||
|
||||
import { RelayClient } from "./relay-client.js";
|
||||
import type { SimplePool, SubCloser } from "nostr-tools/pool";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makePrivateKey(): Uint8Array {
|
||||
let key: Uint8Array;
|
||||
do {
|
||||
key = generateRandomBytes(32);
|
||||
} while (typeof secp256k1.derivePublicKeyCompressed(key) === "string");
|
||||
return key;
|
||||
}
|
||||
|
||||
interface MockPoolHandles {
|
||||
pool: SimplePool;
|
||||
triggerEose: () => void;
|
||||
triggerClose: (reasons: string[]) => void;
|
||||
publishMock: Mock;
|
||||
}
|
||||
|
||||
function makeMockPool(): MockPoolHandles {
|
||||
let onEose: (() => void) | null = null;
|
||||
let onClose: ((reasons: string[]) => void) | null = null;
|
||||
|
||||
const closeFn = vi.fn();
|
||||
const publishMock = vi.fn(() => [Promise.resolve("")]);
|
||||
|
||||
const pool = {
|
||||
subscribeMany: vi.fn(
|
||||
(
|
||||
_urls: string[],
|
||||
_filter: any,
|
||||
callbacks: {
|
||||
onevent: (event: any) => void;
|
||||
oneose: () => void;
|
||||
onclose: (reasons: string[]) => void;
|
||||
},
|
||||
) => {
|
||||
onEose = callbacks.oneose;
|
||||
onClose = callbacks.onclose;
|
||||
return { close: closeFn } as SubCloser;
|
||||
},
|
||||
),
|
||||
publish: publishMock,
|
||||
close: vi.fn(),
|
||||
} as unknown as SimplePool;
|
||||
|
||||
return {
|
||||
pool,
|
||||
triggerEose: () => onEose?.(),
|
||||
triggerClose: (reasons: string[]) => onClose?.(reasons),
|
||||
publishMock,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(pool: SimplePool) {
|
||||
const privateKey = makePrivateKey();
|
||||
const pairedKey = makePrivateKey();
|
||||
return new RelayClient(
|
||||
{
|
||||
explicitRelayUrls: ["wss://test.relay:443"],
|
||||
signerPrivateKey: privateKey,
|
||||
pairedPublicKey: pairedKey,
|
||||
logNetworkActivity: false,
|
||||
},
|
||||
pool,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RelayClient — publish failure triggers disconnect", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits disconnect on publish failure", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
|
||||
const disconnects: Error[] = [];
|
||||
client.on("disconnect", (err: Error) => disconnects.push(err));
|
||||
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]);
|
||||
|
||||
await expect(
|
||||
client.relay({
|
||||
action: "dapp_ready" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
).rejects.toThrow(); // Promise.any wraps in AggregateError
|
||||
|
||||
expect(disconnects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still throws the error to the caller", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
client.on("disconnect", () => {}); // prevent unhandled
|
||||
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
publishMock.mockReturnValueOnce([Promise.reject(new Error("relay down"))]);
|
||||
|
||||
await expect(
|
||||
client.relay({
|
||||
action: "dapp_ready" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not double-emit disconnect from publish failure + onclose race", async () => {
|
||||
const { pool, triggerEose, triggerClose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
|
||||
const disconnects: Error[] = [];
|
||||
client.on("disconnect", (err: Error) => disconnects.push(err));
|
||||
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]);
|
||||
|
||||
await expect(
|
||||
client.relay({
|
||||
action: "dapp_ready" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Subscription onclose also fires (race condition)
|
||||
triggerClose(["relay gone"]);
|
||||
expect(disconnects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconnect resets the disconnect guard so future failures emit again", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
|
||||
const disconnects: Error[] = [];
|
||||
client.on("disconnect", (err: Error) => disconnects.push(err));
|
||||
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
// First publish failure
|
||||
publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 1"))]);
|
||||
await client
|
||||
.relay({ action: "dapp_ready" as any, time: 1 })
|
||||
.catch(() => {});
|
||||
expect(disconnects).toHaveLength(1);
|
||||
|
||||
// Reconnect
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
// Second publish failure — should emit again
|
||||
publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 2"))]);
|
||||
await client
|
||||
.relay({ action: "dapp_ready" as any, time: 2 })
|
||||
.catch(() => {});
|
||||
expect(disconnects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RelayClient — isConnected", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns false before connect", () => {
|
||||
const { pool } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
expect(client.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after connect", async () => {
|
||||
const { pool } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
await client.connect();
|
||||
expect(client.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after disconnect", async () => {
|
||||
const { pool } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
await client.connect();
|
||||
await client.disconnect();
|
||||
expect(client.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunking — sender path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RelayClient — chunk sender path", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not chunk small messages (regression guard)", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
client.setPeerCapabilities({ chunk: true });
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
await client.relay({
|
||||
action: "dapp_ready" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws a clear error for oversized messages when peer lacks chunk support", async () => {
|
||||
const { pool, triggerEose } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
// Intentionally NOT calling setPeerCapabilities({chunk: true})
|
||||
client.on("disconnect", () => {}); // prevent unhandled
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
const huge = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
payload: "x".repeat(200_000),
|
||||
};
|
||||
|
||||
await expect(client.relay(huge)).rejects.toThrow(
|
||||
/does not advertise the 'chunk' transport extension/,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits oversized messages into multiple publish calls when peer supports chunking", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
client.setPeerCapabilities({ chunk: true });
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
const huge = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
payload: "x".repeat(200_000),
|
||||
};
|
||||
|
||||
await client.relay(huge);
|
||||
|
||||
// 200 KB payload should produce multiple chunks; each chunk is one publish.
|
||||
expect(publishMock.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("setPeerCapabilities only updates provided keys", async () => {
|
||||
const { pool, triggerEose } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
// Enable, then call with empty object — should not disable
|
||||
client.setPeerCapabilities({ chunk: true });
|
||||
client.setPeerCapabilities({});
|
||||
|
||||
const { publishMock } = makeMockPool(); // fresh count - not really needed
|
||||
void publishMock;
|
||||
|
||||
const huge = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
payload: "x".repeat(200_000),
|
||||
};
|
||||
// Should not throw, because chunk capability remains enabled
|
||||
await expect(client.relay(huge)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,19 +3,18 @@
|
|||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { throwUnless, unwrap } from "./primitives.js";
|
||||
import {
|
||||
SimplePool,
|
||||
useWebSocketImplementation,
|
||||
type SubCloser,
|
||||
} from "nostr-tools/pool";
|
||||
import { wrapEvent, unwrapEvent } from "nostr-tools/nip59";
|
||||
import type { NostrEvent } from "nostr-tools/core";
|
||||
import WebSocket from "isomorphic-ws";
|
||||
import NDK, {
|
||||
NDKPrivateKeySigner,
|
||||
NDKUser,
|
||||
NDKEvent,
|
||||
NDKKind,
|
||||
giftWrap,
|
||||
giftUnwrap,
|
||||
NDKSubscription,
|
||||
} from "@nostr-dev-kit/ndk";
|
||||
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import {
|
||||
ChunkMessage,
|
||||
isChunkMessage,
|
||||
isProtocolMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
|
|
@ -23,16 +22,6 @@ import {
|
|||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
import { MessageQueue } from "./message-queue.js";
|
||||
import { debug, error as logError, Scope } from "./log.js";
|
||||
import {
|
||||
ChunkReassembler,
|
||||
needsChunking,
|
||||
splitIntoChunks,
|
||||
} from "./transforms/chunk.js";
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const KIND_GIFT_WRAP = 1059;
|
||||
const KIND_PRIVATE_DIRECT_MESSAGE = 14;
|
||||
|
||||
export interface RelayClientConfig {
|
||||
explicitRelayUrls: string[];
|
||||
|
|
@ -42,32 +31,14 @@ export interface RelayClientConfig {
|
|||
}
|
||||
|
||||
export class RelayClient extends EventEmitter {
|
||||
// Keyed by "walletPubkeyHex:dappPubkeyHex". Persists the high-water mark
|
||||
// across RelayClient instance teardowns within the same JS session so that
|
||||
// reconnects after an explicit disconnect()+connect() still filter
|
||||
// relay-replayed messages from the prior session.
|
||||
private static readonly sessionTimestamps = new Map<string, number>();
|
||||
|
||||
private pool: SimplePool;
|
||||
private sharedPool: boolean;
|
||||
private pairedPubkeyHex: string;
|
||||
private ndk: NDK;
|
||||
private paired: NDKUser;
|
||||
private config: RelayClientConfig;
|
||||
private subscription: SubCloser | null = null;
|
||||
private messageSubscription: NDKSubscription | null = null;
|
||||
private myPubkey: Uint8Array;
|
||||
private myPubkeyHex: string;
|
||||
private lastProcessedTimestamp: number = 0;
|
||||
private messageQueue: MessageQueue;
|
||||
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private disconnecting: boolean = false;
|
||||
|
||||
/// Capability flag: peer advertised support for the `chunk` transport
|
||||
/// extension in its dapp_ready / wallet_ready. Set via setPeerCapabilities.
|
||||
private peerSupportsChunk: boolean = false;
|
||||
|
||||
/// Receiver-side reassembly buffer. Always active — if no chunks arrive it
|
||||
/// stays empty. Started in connect(), stopped in disconnect().
|
||||
private reassembler: ChunkReassembler;
|
||||
|
||||
private sequence: number = Math.floor(
|
||||
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
|
||||
|
|
@ -89,34 +60,23 @@ export class RelayClient extends EventEmitter {
|
|||
}
|
||||
>();
|
||||
|
||||
private get sessionKey(): string | null {
|
||||
return this.pairedPubkeyHex
|
||||
? `${this.myPubkeyHex}:${this.pairedPubkeyHex}`
|
||||
: null;
|
||||
}
|
||||
|
||||
constructor(config: RelayClientConfig, pool?: SimplePool) {
|
||||
constructor(config: RelayClientConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
logNetworkActivity: true,
|
||||
...config,
|
||||
};
|
||||
this.pool = pool ?? new SimplePool({ enablePing: true });
|
||||
this.sharedPool = pool !== undefined;
|
||||
this.ndk = new NDK({
|
||||
explicitRelayUrls: this.config.explicitRelayUrls,
|
||||
signer: new NDKPrivateKeySigner(this.config.signerPrivateKey),
|
||||
enableOutboxModel: false,
|
||||
autoConnectUserRelays: false,
|
||||
});
|
||||
|
||||
this.messageQueue = new MessageQueue({
|
||||
logActivity: this.config.logNetworkActivity,
|
||||
});
|
||||
|
||||
// Reassembled messages take the same post-decryption path as unchunked ones.
|
||||
// Chunks themselves have already passed the peer filter and timestamp dedup
|
||||
// on ingress (see routeIncoming), so the assembled message is handed directly
|
||||
// to the application-level handler.
|
||||
this.reassembler = new ChunkReassembler(
|
||||
(msg) => this.handleRelayMessage(msg),
|
||||
!!this.config.logNetworkActivity,
|
||||
);
|
||||
|
||||
this.myPubkey = unwrap(
|
||||
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
|
||||
);
|
||||
|
|
@ -127,17 +87,10 @@ export class RelayClient extends EventEmitter {
|
|||
this.config.pairedPublicKey.length === 33
|
||||
? this.config.pairedPublicKey.slice(1)
|
||||
: this.config.pairedPublicKey;
|
||||
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
|
||||
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
|
||||
} else {
|
||||
this.pairedPubkeyHex = "";
|
||||
this.paired = new NDKUser({ pubkey: "" });
|
||||
}
|
||||
|
||||
// Restore persisted high-water mark for this wallet+dapp pair (survives
|
||||
// instance recreation within the same JS session).
|
||||
const saved = this.sessionKey
|
||||
? RelayClient.sessionTimestamps.get(this.sessionKey)
|
||||
: undefined;
|
||||
if (saved) this.lastProcessedTimestamp = saved;
|
||||
}
|
||||
|
||||
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
|
||||
|
|
@ -146,27 +99,10 @@ export class RelayClient extends EventEmitter {
|
|||
pairedPublicKey.length === 33
|
||||
? pairedPublicKey.slice(1)
|
||||
: pairedPublicKey;
|
||||
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
|
||||
|
||||
// Now that we have the full key, restore any persisted timestamp.
|
||||
const saved = RelayClient.sessionTimestamps.get(this.sessionKey!);
|
||||
if (saved && saved > this.lastProcessedTimestamp) {
|
||||
this.lastProcessedTimestamp = saved;
|
||||
}
|
||||
|
||||
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
|
||||
this.emit("paired");
|
||||
}
|
||||
|
||||
/// Set transport-level capability flags based on the peer's advertisement in
|
||||
/// its dapp_ready / wallet_ready `extensions` field. Called by the connection
|
||||
/// manager after the handshake. New capability keys are additive — callers
|
||||
/// may omit any they don't set.
|
||||
setPeerCapabilities(caps: { chunk?: boolean }): void {
|
||||
if (caps.chunk !== undefined) {
|
||||
this.peerSupportsChunk = caps.chunk;
|
||||
}
|
||||
}
|
||||
|
||||
getPublicKey(): Uint8Array {
|
||||
return this.myPubkey;
|
||||
}
|
||||
|
|
@ -182,68 +118,129 @@ export class RelayClient extends EventEmitter {
|
|||
return !this.config.pairedPublicKey.every((byte) => byte === 0);
|
||||
}
|
||||
|
||||
private emitDisconnect(error: Error): void {
|
||||
if (this.disconnecting) return;
|
||||
this.disconnecting = true;
|
||||
this.emit("disconnect", error);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Connecting to relay...`);
|
||||
}
|
||||
|
||||
this.disconnecting = false;
|
||||
this.reassembler.start();
|
||||
|
||||
if (this.lastProcessedTimestamp === 0) {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
|
||||
}
|
||||
|
||||
try {
|
||||
this.subscription = this.pool.subscribeMany(
|
||||
this.config.explicitRelayUrls,
|
||||
{ kinds: [KIND_GIFT_WRAP], "#p": [this.myPubkeyHex] },
|
||||
await this.ndk.connect();
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `NDK connect() resolved`);
|
||||
}
|
||||
|
||||
this.messageSubscription = this.ndk.subscribe(
|
||||
{
|
||||
onevent: (event: NostrEvent) => this.handleWrappedEvent(event),
|
||||
oneose: () => {
|
||||
if (this.readyTimeoutId) {
|
||||
clearTimeout(this.readyTimeoutId);
|
||||
this.readyTimeoutId = null;
|
||||
}
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `EOSE received, relay connected`);
|
||||
}
|
||||
this.messageQueue.setReady((msg) => this.publishMessage(msg));
|
||||
this.emit("connection");
|
||||
},
|
||||
onclose: (reasons: string[]) => {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Subscription closed: ${reasons.join(", ")}`);
|
||||
}
|
||||
this.emitDisconnect(new Error("Subscription closed"));
|
||||
},
|
||||
kinds: [NDKKind.GiftWrap],
|
||||
"#p": [this.myPubkeyHex],
|
||||
},
|
||||
{ closeOnEose: false },
|
||||
);
|
||||
|
||||
// Fallback: if EOSE doesn't arrive within 5 seconds, assume ready
|
||||
this.readyTimeoutId = setTimeout(() => {
|
||||
if (!this.messageQueue.getReady()) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `EOSE timeout, assuming ready`);
|
||||
this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => {
|
||||
try {
|
||||
const signer = this.ndk.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
this.messageQueue.setReady((msg) => this.publishMessage(msg));
|
||||
this.emit("connection");
|
||||
|
||||
const rumor = await giftUnwrap(wrappedEvent, undefined, signer);
|
||||
|
||||
if (rumor.kind === NDKKind.PrivateDirectMessage) {
|
||||
let payload: ProtocolMessage;
|
||||
try {
|
||||
payload = JSON.parse(rumor.content);
|
||||
} catch (e) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
"Failed to parse message content as JSON:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!payload.time ||
|
||||
(this.lastProcessedTimestamp > 0 &&
|
||||
payload.time < this.lastProcessedTimestamp)
|
||||
) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// wallet_ready carries the key exchange data (public_key + secret) so it
|
||||
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
|
||||
const isKeyExchangeMessage =
|
||||
payload.action === RelayMsgAction.WalletReady;
|
||||
|
||||
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
|
||||
const pairedNostrPubkey =
|
||||
this.config.pairedPublicKey.length === 33
|
||||
? binToHex(this.config.pairedPublicKey.slice(1))
|
||||
: binToHex(this.config.pairedPublicKey);
|
||||
if (rumor.pubkey !== pairedNostrPubkey) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Received message ${payload.action} from relay`,
|
||||
);
|
||||
}
|
||||
this.handleRelayMessage(payload);
|
||||
} else {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, "Error handling incoming message:", error);
|
||||
}
|
||||
this.emitError(error as Error);
|
||||
}
|
||||
this.readyTimeoutId = null;
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
this.messageSubscription.on("close", () => {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, "Subscription closed, connection may be stale");
|
||||
}
|
||||
this.emit("disconnect", new Error("Subscription closed"));
|
||||
});
|
||||
|
||||
this.waitForRelaysReady();
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Subscription created, waiting for relay connection`,
|
||||
`Subscription created and handler set up, emitting connection event`,
|
||||
);
|
||||
}
|
||||
|
||||
this.emit("connection");
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, `Connection failed:`, error);
|
||||
|
|
@ -254,24 +251,11 @@ export class RelayClient extends EventEmitter {
|
|||
|
||||
async disconnect(): Promise<void> {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
|
||||
const key = this.sessionKey;
|
||||
if (key)
|
||||
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
|
||||
this.messageQueue.setNotReady();
|
||||
this.reassembler.stop();
|
||||
|
||||
if (this.readyTimeoutId) {
|
||||
clearTimeout(this.readyTimeoutId);
|
||||
this.readyTimeoutId = null;
|
||||
}
|
||||
|
||||
if (this.subscription) {
|
||||
this.subscription.close();
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
if (!this.sharedPool) {
|
||||
this.pool.close(this.config.explicitRelayUrls);
|
||||
if (this.messageSubscription) {
|
||||
this.messageSubscription.stop();
|
||||
this.messageSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -298,201 +282,82 @@ export class RelayClient extends EventEmitter {
|
|||
}
|
||||
|
||||
private async publishMessage(message: ProtocolMessage): Promise<void> {
|
||||
const serialized = JSON.stringify(message);
|
||||
this.netlog("send", message.action);
|
||||
|
||||
if (!needsChunking(serialized)) {
|
||||
return this.publishSerialized(message.action, serialized);
|
||||
const signer = this.ndk.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
|
||||
// Oversized. Chunk if the peer supports it; otherwise fail loudly with
|
||||
// an actionable message (replacing nostr-tools' cryptic plaintext-size error).
|
||||
if (!this.peerSupportsChunk) {
|
||||
const err = new Error(
|
||||
`Cannot send ${message.action}: message is larger than NIP-44's 65,535-byte ceiling ` +
|
||||
`and the peer does not advertise the 'chunk' transport extension. ` +
|
||||
`Please update the connected wallet/dapp to a version that supports chunked messages.`,
|
||||
);
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const rumor = new NDKEvent(this.ndk);
|
||||
rumor.kind = NDKKind.PrivateDirectMessage;
|
||||
rumor.content = JSON.stringify(message);
|
||||
rumor.created_at = Math.floor(Date.now() / 1000);
|
||||
rumor.tags = [["p", this.paired.pubkey]];
|
||||
|
||||
const chunks = splitIntoChunks(serialized);
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunking ${message.action}: ${chunks.length} chunks (serialized ~${serialized.length} bytes)`,
|
||||
);
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
await this.publishSerialized(
|
||||
`${message.action}[chunk ${chunk.index + 1}/${chunk.total}]`,
|
||||
JSON.stringify(chunk),
|
||||
);
|
||||
}
|
||||
}
|
||||
const wrappedEvent = await giftWrap(rumor, this.paired, signer);
|
||||
|
||||
/// Wrap and publish one gift-wrap event. Used for both unchunked messages
|
||||
/// and individual chunks. `displayAction` is only used for logs.
|
||||
private async publishSerialized(
|
||||
displayAction: string,
|
||||
serialized: string,
|
||||
): Promise<void> {
|
||||
this.netlog("send", displayAction);
|
||||
|
||||
const wrapped = wrapEvent(
|
||||
{
|
||||
kind: KIND_PRIVATE_DIRECT_MESSAGE,
|
||||
content: serialized,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", this.pairedPubkeyHex]],
|
||||
},
|
||||
this.config.signerPrivateKey,
|
||||
this.pairedPubkeyHex,
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
this.pool.publish(this.config.explicitRelayUrls, wrapped),
|
||||
);
|
||||
|
||||
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
||||
const rejected = results.filter((r) => r.status === "rejected");
|
||||
|
||||
if (rejected.length > 0 && this.config.logNetworkActivity) {
|
||||
for (const r of rejected) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Failed to publish ${displayAction} to a relay:`,
|
||||
(r as PromiseRejectedResult).reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fulfilled.length === 0) {
|
||||
const error = new Error(
|
||||
`Failed to publish ${displayAction} to all relays`,
|
||||
);
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, error.message);
|
||||
}
|
||||
this.emitDisconnect(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Published message ${displayAction} to ${fulfilled.length}/${results.length} relay(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleWrappedEvent(wrappedEvent: NostrEvent): void {
|
||||
try {
|
||||
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
|
||||
|
||||
if (rumor.kind !== KIND_PRIVATE_DIRECT_MESSAGE) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
await wrappedEvent.publish();
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Published message ${message.action} to relay`);
|
||||
}
|
||||
|
||||
let payload: ProtocolMessage;
|
||||
try {
|
||||
payload = JSON.parse(rumor.content);
|
||||
} catch (e) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, "Failed to parse message content as JSON:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.routeIncoming(payload, rumor.pubkey);
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, "Error handling incoming message:", error);
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Failed to publish message ${message.action}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
this.emitError(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply timestamp dedup + peer filter, then dispatch to chunk reassembly
|
||||
/// or the application-level handler. Called from handleWrappedEvent (one
|
||||
/// path: unwrap → route). Kept separate to keep handleWrappedEvent focused
|
||||
/// on decryption and to allow future transport-layer transforms to invoke
|
||||
/// this path with already-decoded payloads.
|
||||
private routeIncoming(payload: ProtocolMessage, fromPubkey: string): void {
|
||||
if (
|
||||
!payload.time ||
|
||||
(this.lastProcessedTimestamp > 0 &&
|
||||
payload.time < this.lastProcessedTimestamp)
|
||||
) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
|
||||
private async waitForRelaysReady(): Promise<void> {
|
||||
const maxWaitTime = 5000;
|
||||
const checkInterval = 100;
|
||||
const startTime = Date.now();
|
||||
|
||||
const checkRelays = async (): Promise<void> => {
|
||||
const pool = (this.ndk as any).pool;
|
||||
if (pool) {
|
||||
const relays = pool.relays || [];
|
||||
// NDKRelayStatus: DISCONNECTED=1, CONNECTED=5, AUTHENTICATED=8
|
||||
const connectedRelays = Array.from(relays.values()).filter(
|
||||
(relay: any) => relay.status >= 5,
|
||||
);
|
||||
|
||||
if (connectedRelays.length > 0) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Relays ready (${connectedRelays.length} connected), processing queued messages`,
|
||||
);
|
||||
}
|
||||
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance the high-water mark so relay replays are filtered on reconnect.
|
||||
// Also persist to the static session cache so a fresh RelayClient instance
|
||||
// for the same wallet+dapp pair inherits this mark.
|
||||
if (payload.time > this.lastProcessedTimestamp) {
|
||||
this.lastProcessedTimestamp = payload.time;
|
||||
const key = this.sessionKey;
|
||||
if (key)
|
||||
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
|
||||
}
|
||||
|
||||
// wallet_ready carries the key exchange data (public_key + secret) so it
|
||||
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
|
||||
const isKeyExchangeMessage = payload.action === RelayMsgAction.WalletReady;
|
||||
|
||||
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
|
||||
const pairedNostrPubkey =
|
||||
this.config.pairedPublicKey.length === 33
|
||||
? binToHex(this.config.pairedPublicKey.slice(1))
|
||||
: binToHex(this.config.pairedPublicKey);
|
||||
if (fromPubkey !== pairedNostrPubkey) {
|
||||
if (Date.now() - startTime < maxWaitTime) {
|
||||
setTimeout(checkRelays, checkInterval);
|
||||
} else {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring '${payload.action}' message from unknown peer: ${fromPubkey} (expected: ${pairedNostrPubkey})`,
|
||||
"Relay ready check timeout, assuming ready and processing queued messages",
|
||||
);
|
||||
}
|
||||
return;
|
||||
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Transport-level branch: a ChunkMessage is routed to the reassembler,
|
||||
// which will emit a reassembled ProtocolMessage via handleRelayMessage
|
||||
// once all pieces arrive.
|
||||
if (isChunkMessage(payload)) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Received chunk ${payload.index + 1}/${payload.total} (msgId=${payload.msgId})`,
|
||||
);
|
||||
}
|
||||
this.reassembler.ingest(payload as ChunkMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Received message ${payload.action} from relay`);
|
||||
}
|
||||
this.handleRelayMessage(payload);
|
||||
setTimeout(checkRelays, 200);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.subscription !== null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private netlog(
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
// 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 } from "vitest";
|
||||
import { binToHex } from "@bitauth/libauth";
|
||||
import {
|
||||
parseExtendedJson,
|
||||
parseExtendedJsonValue,
|
||||
isExtendedJsonFormat,
|
||||
toUint8Array,
|
||||
toBigInt,
|
||||
} from "./serialize.js";
|
||||
|
||||
describe("wc-serialize", () => {
|
||||
describe("parseExtendedJsonValue", () => {
|
||||
it("parses bigint format", () => {
|
||||
expect(parseExtendedJsonValue("<bigint: 546n>")).toBe(546n);
|
||||
expect(parseExtendedJsonValue("<bigint: 0n>")).toBe(0n);
|
||||
expect(parseExtendedJsonValue("<bigint: 100000000n>")).toBe(100000000n);
|
||||
});
|
||||
|
||||
it("parses Uint8Array format", () => {
|
||||
const result = parseExtendedJsonValue("<Uint8Array: 0xabcd>");
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(binToHex(result as Uint8Array)).toBe("abcd");
|
||||
});
|
||||
|
||||
it("returns original string for non-extended values", () => {
|
||||
expect(parseExtendedJsonValue("hello")).toBe("hello");
|
||||
expect(parseExtendedJsonValue("123")).toBe("123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExtendedJsonFormat", () => {
|
||||
it("detects bigint format", () => {
|
||||
expect(isExtendedJsonFormat("<bigint: 100n>")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects Uint8Array format", () => {
|
||||
expect(isExtendedJsonFormat("<Uint8Array: 0xabcd>")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects plain strings", () => {
|
||||
expect(isExtendedJsonFormat("hello")).toBe(false);
|
||||
expect(isExtendedJsonFormat("123")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseExtendedJson", () => {
|
||||
it("parses JSON with mixed extended values", () => {
|
||||
const json = JSON.stringify({
|
||||
value: "<bigint: 200000n>",
|
||||
data: "<Uint8Array: 0x76a9>",
|
||||
name: "test",
|
||||
count: 42,
|
||||
});
|
||||
const result = parseExtendedJson(json);
|
||||
expect(result.value).toBe(200000n);
|
||||
expect(result.data).toBeInstanceOf(Uint8Array);
|
||||
expect(binToHex(result.data)).toBe("76a9");
|
||||
expect(result.name).toBe("test");
|
||||
expect(result.count).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toUint8Array", () => {
|
||||
it("passes through Uint8Array", () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
expect(toUint8Array(bytes)).toBe(bytes);
|
||||
});
|
||||
|
||||
it("converts hex string", () => {
|
||||
const result = toUint8Array("abcd");
|
||||
expect(binToHex(result)).toBe("abcd");
|
||||
});
|
||||
|
||||
it("converts extended JSON format", () => {
|
||||
const result = toUint8Array("<Uint8Array: 0xabcd>");
|
||||
expect(binToHex(result)).toBe("abcd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toBigInt", () => {
|
||||
it("passes through bigint", () => {
|
||||
expect(toBigInt(546n)).toBe(546n);
|
||||
});
|
||||
|
||||
it("converts number", () => {
|
||||
expect(toBigInt(546)).toBe(546n);
|
||||
});
|
||||
|
||||
it("converts numeric string", () => {
|
||||
expect(toBigInt("546")).toBe(546n);
|
||||
});
|
||||
|
||||
it("converts extended JSON format", () => {
|
||||
expect(toBigInt("<bigint: 546n>")).toBe(546n);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* Serialization helpers for converting between native libauth types and the
|
||||
* relay-safe JSON format used by the WizardConnect protocol.
|
||||
*
|
||||
* The relay transmits messages as JSON, which cannot represent BigInt or
|
||||
* Uint8Array natively. This module provides the canonical encoding:
|
||||
*
|
||||
* - Uint8Array → hex string (or `<Uint8Array: 0x...>` extended format)
|
||||
* - BigInt → `<bigint: Xn>` string
|
||||
*
|
||||
* Both dapps and wallets should use these helpers to ensure interoperability.
|
||||
*/
|
||||
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deserialization (relay JSON → native types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BIGINT_RE = /^<bigint: (?<bigint>[0-9]*)n>$/;
|
||||
const UINT8_RE = /^<Uint8Array: 0x(?<hex>[0-9a-f]*)>$/u;
|
||||
|
||||
/**
|
||||
* Parse a full JSON string that may contain extended-format values.
|
||||
* Handles both `<Uint8Array: 0x...>` and `<bigint: ...n>` formats.
|
||||
*/
|
||||
export function parseExtendedJson(jsonString: string): any {
|
||||
return JSON.parse(jsonString, (_key, value) => {
|
||||
if (typeof value === "string") {
|
||||
const bigintMatch = value.match(BIGINT_RE);
|
||||
if (bigintMatch) return BigInt(bigintMatch[1]);
|
||||
const uint8Match = value.match(UINT8_RE);
|
||||
if (uint8Match) return hexToBin(uint8Match[1]);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains extended JSON markers.
|
||||
*/
|
||||
export function isExtendedJsonFormat(str: string): boolean {
|
||||
return UINT8_RE.test(str) || BIGINT_RE.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single extended JSON value string to its native type.
|
||||
* Returns the original string if it doesn't match any known format.
|
||||
*/
|
||||
export function parseExtendedJsonValue(
|
||||
value: string,
|
||||
): Uint8Array | bigint | string {
|
||||
const bigintMatch = value.match(BIGINT_RE);
|
||||
if (bigintMatch) return BigInt(bigintMatch[1]);
|
||||
const uint8Match = value.match(UINT8_RE);
|
||||
if (uint8Match) return hexToBin(uint8Match[1]);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to Uint8Array. Accepts:
|
||||
* - Uint8Array (returned as-is)
|
||||
* - hex string
|
||||
* - extended JSON format string (`<Uint8Array: 0x...>`)
|
||||
*/
|
||||
export function toUint8Array(value: string | Uint8Array): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (isExtendedJsonFormat(value))
|
||||
return parseExtendedJsonValue(value) as Uint8Array;
|
||||
return hexToBin(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to bigint. Accepts:
|
||||
* - bigint (returned as-is)
|
||||
* - number
|
||||
* - numeric string
|
||||
* - extended JSON format string (`<bigint: Xn>`)
|
||||
*/
|
||||
export function toBigInt(value: string | number | bigint): bigint {
|
||||
if (typeof value === "bigint") return value;
|
||||
if (typeof value === "string") {
|
||||
if (isExtendedJsonFormat(value))
|
||||
return parseExtendedJsonValue(value) as bigint;
|
||||
return BigInt(value);
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
ChunkReassembler,
|
||||
CHUNK_REQUIRED_BYTES,
|
||||
CHUNK_RAW_BYTES,
|
||||
REASSEMBLY_TTL_MS,
|
||||
chunkExtensionAdvertisement,
|
||||
needsChunking,
|
||||
peerSupportsChunk,
|
||||
splitIntoChunks,
|
||||
} from "./chunk.js";
|
||||
import {
|
||||
ChunkMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
} from "../protocols/hdwalletv1.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("chunk extension advertisement helpers", () => {
|
||||
it("chunkExtensionAdvertisement returns a version-tagged object", () => {
|
||||
const adv = chunkExtensionAdvertisement();
|
||||
expect(adv).toHaveProperty("version");
|
||||
expect(typeof adv.version).toBe("number");
|
||||
});
|
||||
|
||||
it("peerSupportsChunk true iff `chunk` key present", () => {
|
||||
expect(peerSupportsChunk(undefined)).toBe(false);
|
||||
expect(peerSupportsChunk({})).toBe(false);
|
||||
expect(peerSupportsChunk({ chunk: { version: 1 } })).toBe(true);
|
||||
expect(peerSupportsChunk({ chunk: {} })).toBe(true);
|
||||
// presence of other keys doesn't imply chunk support
|
||||
expect(peerSupportsChunk({ compress: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsChunking", () => {
|
||||
it("returns false for small payloads", () => {
|
||||
expect(needsChunking("hello")).toBe(false);
|
||||
expect(needsChunking("a".repeat(1000))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when UTF-8 bytes exceed the ceiling-minus-overhead", () => {
|
||||
expect(needsChunking("a".repeat(CHUNK_REQUIRED_BYTES + 1))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false right at the threshold", () => {
|
||||
// ASCII: 1 byte per char. CHUNK_REQUIRED_BYTES chars fits.
|
||||
expect(needsChunking("a".repeat(CHUNK_REQUIRED_BYTES))).toBe(false);
|
||||
});
|
||||
|
||||
it("accounts for multibyte UTF-8 (emoji)", () => {
|
||||
// "🦀" = 4 UTF-8 bytes. 20,000 crabs = 80,000 bytes > CHUNK_REQUIRED_BYTES.
|
||||
const s = "🦀".repeat(20_000);
|
||||
expect(needsChunking(s)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("splitIntoChunks", () => {
|
||||
it("produces one chunk for tiny input", () => {
|
||||
const chunks = splitIntoChunks(JSON.stringify({ hello: "world" }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].index).toBe(0);
|
||||
expect(chunks[0].total).toBe(1);
|
||||
});
|
||||
|
||||
it("shares one msgId and one time across chunks", () => {
|
||||
const big = "x".repeat(CHUNK_RAW_BYTES * 5);
|
||||
const chunks = splitIntoChunks(big);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
const firstId = chunks[0].msgId;
|
||||
const firstTime = chunks[0].time;
|
||||
for (const c of chunks) {
|
||||
expect(c.msgId).toBe(firstId);
|
||||
expect(c.time).toBe(firstTime);
|
||||
}
|
||||
});
|
||||
|
||||
it("every chunk passes the NIP-17 gift-wrap round-trip without exceeding NIP-44", async () => {
|
||||
// The real constraint isn't just that the chunk's JSON fits in 65,535
|
||||
// plaintext bytes — it's that the OUTER gift-wrap's plaintext (which
|
||||
// contains the seal, which contains the 4/3×-expanded encrypted rumor)
|
||||
// also fits. This test exercises the full wrapEvent path.
|
||||
const { wrapEvent } = await import("nostr-tools/nip59");
|
||||
const { generateSecretKey, getPublicKey } =
|
||||
await import("nostr-tools/pure");
|
||||
const senderPriv = generateSecretKey();
|
||||
const recipPub = getPublicKey(generateSecretKey());
|
||||
|
||||
const big = "x".repeat(CHUNK_RAW_BYTES * 10);
|
||||
const chunks = splitIntoChunks(big);
|
||||
for (const c of chunks) {
|
||||
expect(() =>
|
||||
wrapEvent(
|
||||
{
|
||||
kind: 14,
|
||||
content: JSON.stringify(c),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", recipPub]],
|
||||
},
|
||||
senderPriv,
|
||||
recipPub,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses sequential indices starting at 0", () => {
|
||||
const big = "x".repeat(CHUNK_RAW_BYTES * 4);
|
||||
const chunks = splitIntoChunks(big);
|
||||
chunks.forEach((c, i) => {
|
||||
expect(c.index).toBe(i);
|
||||
expect(c.total).toBe(chunks.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts caller-supplied msgId and time", () => {
|
||||
const chunks = splitIntoChunks("hello", {
|
||||
msgId: "fixed-id",
|
||||
time: 12345,
|
||||
});
|
||||
expect(chunks[0].msgId).toBe("fixed-id");
|
||||
expect(chunks[0].time).toBe(12345);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reassembler — round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ChunkReassembler round-trip", () => {
|
||||
it("reassembles a small message", () => {
|
||||
const original: ProtocolMessage = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: ProtocolMessage[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles a ~2MB message (simulates signed tx hex response)", () => {
|
||||
const original = {
|
||||
action: "sign_transaction_response",
|
||||
time: 1000,
|
||||
sequence: 42,
|
||||
signedTransaction: "ab".repeat(1_000_000), // 2 MB hex
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
expect(chunks.length).toBeGreaterThan(30);
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles when chunks arrive in reverse order", () => {
|
||||
const original = {
|
||||
action: "sign_transaction_request",
|
||||
time: 1,
|
||||
payload: "x".repeat(CHUNK_RAW_BYTES * 3),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (let i = chunks.length - 1; i >= 0; i--) r.ingest(chunks[i]);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles when chunks arrive in shuffled order", () => {
|
||||
const original = {
|
||||
action: "sign_transaction_request",
|
||||
time: 1,
|
||||
payload: "x".repeat(CHUNK_RAW_BYTES * 4),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const shuffled = [...chunks].sort(() => Math.random() - 0.5);
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of shuffled) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips multibyte UTF-8 content", () => {
|
||||
const original = {
|
||||
action: "custom_action",
|
||||
time: 1,
|
||||
crab: "🦀".repeat(20_000), // 80 KB of 4-byte codepoints
|
||||
japanese: "こんにちは世界".repeat(5_000),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reassembler — duplicate / malformed / TTL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ChunkReassembler edge cases", () => {
|
||||
it("duplicate chunks are idempotent (delivers exactly once)", () => {
|
||||
const original = {
|
||||
action: "a",
|
||||
time: 1,
|
||||
x: "y".repeat(CHUNK_RAW_BYTES * 2),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
// Replay everything a second time
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not deliver if a chunk is missing", () => {
|
||||
const original = {
|
||||
action: "a",
|
||||
time: 1,
|
||||
x: "y".repeat(CHUNK_RAW_BYTES * 3),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (let i = 0; i < chunks.length - 1; i++) r.ingest(chunks[i]);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops a chunk whose total disagrees with the in-flight entry", () => {
|
||||
const chunkA: ChunkMessage = {
|
||||
action: RelayMsgAction.Chunk,
|
||||
time: 1,
|
||||
msgId: "m",
|
||||
index: 0,
|
||||
total: 3,
|
||||
data: "AAAA",
|
||||
};
|
||||
const chunkBadTotal: ChunkMessage = { ...chunkA, index: 1, total: 99 };
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
r.ingest(chunkA);
|
||||
r.ingest(chunkBadTotal);
|
||||
expect(r.bufferCount).toBe(1);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reassembled payload that is not a valid ProtocolMessage is dropped", () => {
|
||||
// Craft a single chunk carrying junk JSON
|
||||
const junk = JSON.stringify({ not: "a protocol message" });
|
||||
const chunks = splitIntoChunks(junk);
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reassembled payload with invalid base64/UTF-8 is dropped without crashing", () => {
|
||||
const chunk: ChunkMessage = {
|
||||
action: RelayMsgAction.Chunk,
|
||||
time: 1,
|
||||
msgId: "bad",
|
||||
index: 0,
|
||||
total: 1,
|
||||
data: "!!!not-base64!!!",
|
||||
};
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
expect(() => r.ingest(chunk)).not.toThrow();
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("TTL sweeper evicts incomplete entries", () => {
|
||||
let clock = 0;
|
||||
const r = new ChunkReassembler(
|
||||
() => {},
|
||||
false,
|
||||
() => clock,
|
||||
);
|
||||
const original = {
|
||||
action: "a",
|
||||
time: 1,
|
||||
x: "y".repeat(CHUNK_RAW_BYTES * 3),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
r.ingest(chunks[0]);
|
||||
expect(r.bufferCount).toBe(1);
|
||||
|
||||
// Advance clock past TTL
|
||||
clock += REASSEMBLY_TTL_MS + 1;
|
||||
r.sweep();
|
||||
expect(r.bufferCount).toBe(0);
|
||||
});
|
||||
|
||||
it("start() installs a periodic sweeper that stop() clears", () => {
|
||||
vi.useFakeTimers();
|
||||
const r = new ChunkReassembler(() => {}, false);
|
||||
r.start();
|
||||
// Shouldn't throw on repeat start
|
||||
r.start();
|
||||
vi.advanceTimersByTime(100_000);
|
||||
r.stop();
|
||||
// Shouldn't throw on repeat stop
|
||||
r.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* Transport-level chunking.
|
||||
*
|
||||
* NIP-44 caps plaintext at 65,535 bytes (the plaintext length is encoded as a
|
||||
* U16BE prefix in the wire format — structural, not a configurable guardrail).
|
||||
* This module splits oversized ProtocolMessages into a sequence of
|
||||
* ChunkMessages that each fit under the ceiling, and reassembles them on the
|
||||
* receiver.
|
||||
*
|
||||
* Fire-and-forget: chunks are published individually through the same
|
||||
* multi-relay path as any other message, with no per-chunk ACKs. Receiver
|
||||
* buffers by msgId, applies a TTL, and delivers the assembled ProtocolMessage
|
||||
* once complete.
|
||||
*
|
||||
* See docs/transport.md for wire format and failure modes.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChunkMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
isProtocolMessage,
|
||||
} from "../protocols/hdwalletv1.js";
|
||||
import { debug, error as logError, Scope } from "../log.js";
|
||||
|
||||
/// Extension key advertised in base-level `extensions` on dapp_ready / wallet_ready.
|
||||
export const CHUNK_EXTENSION_NAME = "chunk";
|
||||
export const CHUNK_EXTENSION_VERSION = 1;
|
||||
|
||||
/// NIP-44 plaintext hard limit.
|
||||
export const NIP44_MAX_PLAINTEXT = 65535;
|
||||
|
||||
/// NIP-17 gift-wrap applies NIP-44 encryption twice: once to the rumor
|
||||
/// (inner) and once to the seal (outer). The 65,535-byte cap applies to the
|
||||
/// plaintext of each layer. What we hand to wrapEvent becomes the rumor
|
||||
/// content; that rumor is then JSON-serialized, padded (up to multiples of
|
||||
/// 8192 for sizes in this range), encrypted, base64'd, and JSON-wrapped in
|
||||
/// a seal — the outer wrap then encrypts that seal JSON, which must also
|
||||
/// fit under 65,535 plaintext bytes.
|
||||
///
|
||||
/// For a rumor content of C bytes (ASCII) the outer plaintext is roughly:
|
||||
/// (event_shell≈200) + ceil(4/3 × (32 + pad(C + 200) + 32)) + seal_shell≈300
|
||||
/// With pad(~40 KB) = 40,960 this totals ≈55 KB — safely under the cap.
|
||||
/// Above ~40,960 bytes of content the next pad multiple jumps to 49,152,
|
||||
/// pushing the outer plaintext past the cap.
|
||||
|
||||
/// Raw content bytes we'll pack into a single chunk's `data` field (before
|
||||
/// base64). Sized so that after base64 expansion, envelope overhead, and the
|
||||
/// two-layer gift-wrap expansion, the outer plaintext stays well under
|
||||
/// NIP-44's 65,535-byte ceiling. See derivation above.
|
||||
export const CHUNK_RAW_BYTES = 30000;
|
||||
|
||||
/// When JSON.stringify(message)'s UTF-8 byte length exceeds this, chunking
|
||||
/// is required. Derived from the same outer-wrap size analysis: anything
|
||||
/// above ~40,760 bytes of content pushes the outer wrap plaintext over
|
||||
/// 65,535. Conservative headroom built in.
|
||||
export const CHUNK_REQUIRED_BYTES = 40000;
|
||||
|
||||
/// How long an incomplete reassembly buffer survives without progress.
|
||||
/// Sized for a ~35-chunk transfer (2 MB tx-hex response) under congested
|
||||
/// relay conditions.
|
||||
export const REASSEMBLY_TTL_MS = 120_000;
|
||||
|
||||
/// How often the TTL sweeper runs.
|
||||
export const SWEEP_INTERVAL_MS = 10_000;
|
||||
|
||||
/// Returns the extension advertisement value for the `chunk` key in the
|
||||
/// base-level `extensions` field of dapp_ready / wallet_ready.
|
||||
export function chunkExtensionAdvertisement(): { version: number } {
|
||||
return { version: CHUNK_EXTENSION_VERSION };
|
||||
}
|
||||
|
||||
/// True iff the peer's `extensions` object advertises support for chunking.
|
||||
export function peerSupportsChunk(
|
||||
extensions: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
return !!extensions && extensions[CHUNK_EXTENSION_NAME] !== undefined;
|
||||
}
|
||||
|
||||
/// Measure UTF-8 byte length without allocating the full encoded buffer
|
||||
/// (a shallow optimisation for very large payloads).
|
||||
export function utf8ByteLength(s: string): number {
|
||||
return new TextEncoder().encode(s).length;
|
||||
}
|
||||
|
||||
/// True iff a message of this serialized size requires chunking.
|
||||
export function needsChunking(serialized: string): boolean {
|
||||
return utf8ByteLength(serialized) > CHUNK_REQUIRED_BYTES;
|
||||
}
|
||||
|
||||
/// Split a serialized ProtocolMessage into ChunkMessages. All chunks share
|
||||
/// one msgId and one `time` so reassembly is deterministic and the
|
||||
/// reassembled message keeps a coherent timestamp for existing dedup logic.
|
||||
export function splitIntoChunks(
|
||||
serialized: string,
|
||||
opts?: { msgId?: string; time?: number },
|
||||
): ChunkMessage[] {
|
||||
const msgId = opts?.msgId ?? newMsgId();
|
||||
const time = opts?.time ?? Math.floor(Date.now() / 1000);
|
||||
const utf8 = new TextEncoder().encode(serialized);
|
||||
const b64 = bytesToBase64(utf8);
|
||||
// base64 chars per chunk equivalent to CHUNK_RAW_BYTES raw bytes
|
||||
const sliceChars = Math.ceil((CHUNK_RAW_BYTES * 4) / 3);
|
||||
const total = Math.max(1, Math.ceil(b64.length / sliceChars));
|
||||
const chunks: ChunkMessage[] = [];
|
||||
for (let i = 0; i < total; i++) {
|
||||
chunks.push({
|
||||
action: RelayMsgAction.Chunk,
|
||||
time,
|
||||
msgId,
|
||||
index: i,
|
||||
total,
|
||||
data: b64.slice(i * sliceChars, (i + 1) * sliceChars),
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
interface ReassemblyEntry {
|
||||
total: number;
|
||||
chunks: (string | undefined)[];
|
||||
received: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver-side chunk reassembler.
|
||||
*
|
||||
* Owns a msgId-keyed buffer of partial messages, TTL-evicted by a periodic
|
||||
* sweeper. When all chunks for a msgId are present the assembled
|
||||
* ProtocolMessage is handed to `onComplete`.
|
||||
*
|
||||
* Not concurrency-safe — a single instance is owned by a single RelayClient.
|
||||
*/
|
||||
export class ChunkReassembler {
|
||||
private buffers = new Map<string, ReassemblyEntry>();
|
||||
/// msgIds that have already been delivered, kept for a short grace period
|
||||
/// so late-arriving duplicate chunks (e.g. cross-subscription replay after
|
||||
/// reconnect) don't spawn a second reassembly and double-deliver.
|
||||
private completed = new Map<string, number /* expiresAt */>();
|
||||
private sweeperId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly onComplete: (msg: ProtocolMessage) => void,
|
||||
private readonly logActivity: boolean = true,
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.sweeperId !== null) return;
|
||||
this.sweeperId = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.sweeperId !== null) {
|
||||
clearInterval(this.sweeperId);
|
||||
this.sweeperId = null;
|
||||
}
|
||||
this.buffers.clear();
|
||||
this.completed.clear();
|
||||
}
|
||||
|
||||
/// For tests. Otherwise start() schedules this automatically.
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
for (const [id, entry] of this.buffers) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.buffers.delete(id);
|
||||
if (this.logActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunk reassembly timeout: ${id} (${entry.received}/${entry.total} received)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, expiresAt] of this.completed) {
|
||||
if (expiresAt <= now) this.completed.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest one chunk. If this completes the message, onComplete fires.
|
||||
/// Duplicate chunks (same msgId/index) are idempotent. Malformed chunks
|
||||
/// are dropped silently.
|
||||
ingest(chunk: ChunkMessage): void {
|
||||
if (this.completed.has(chunk.msgId)) {
|
||||
// Already delivered this message; drop late duplicates.
|
||||
return;
|
||||
}
|
||||
let entry = this.buffers.get(chunk.msgId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
total: chunk.total,
|
||||
chunks: new Array(chunk.total),
|
||||
received: 0,
|
||||
expiresAt: this.now() + REASSEMBLY_TTL_MS,
|
||||
};
|
||||
this.buffers.set(chunk.msgId, entry);
|
||||
} else if (entry.total !== chunk.total) {
|
||||
// Protocol invariant violation — peer changed total mid-stream. Drop.
|
||||
if (this.logActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Chunk total mismatch for ${chunk.msgId}: ${chunk.total} vs expected ${entry.total}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.chunks[chunk.index] !== undefined) {
|
||||
// Duplicate — already have this slot. SimplePool dedupes by event id
|
||||
// within a subscription; this guards against the cross-subscription
|
||||
// replay case after a reconnect.
|
||||
return;
|
||||
}
|
||||
entry.chunks[chunk.index] = chunk.data;
|
||||
entry.received++;
|
||||
|
||||
if (entry.received !== entry.total) return;
|
||||
|
||||
// Assemble and deliver.
|
||||
this.buffers.delete(chunk.msgId);
|
||||
// Grace period matches reassembly TTL — sufficient to catch any laggard
|
||||
// duplicates from a slow relay that were in flight when we completed.
|
||||
this.completed.set(chunk.msgId, this.now() + REASSEMBLY_TTL_MS);
|
||||
let reassembled: ProtocolMessage;
|
||||
try {
|
||||
const fullB64 = entry.chunks.join("");
|
||||
const bytes = base64ToBytes(fullB64);
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed = JSON.parse(json);
|
||||
if (!isProtocolMessage(parsed)) {
|
||||
if (this.logActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Reassembled chunk msgId=${chunk.msgId} is not a valid ProtocolMessage`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reassembled = parsed;
|
||||
} catch (e) {
|
||||
if (this.logActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Chunk reassembly failed for msgId=${chunk.msgId}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.logActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Reassembled chunked message: action=${reassembled.action} chunks=${entry.total}`,
|
||||
);
|
||||
}
|
||||
this.onComplete(reassembled);
|
||||
}
|
||||
|
||||
/// Test helper: number of in-flight partial messages.
|
||||
get bufferCount(): number {
|
||||
return this.buffers.size;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-platform base64 (no dependency on Buffer or DOM-specific APIs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
const slice = bytes.subarray(i, i + CHUNK);
|
||||
binary += String.fromCharCode.apply(null, slice as unknown as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function newMsgId(): string {
|
||||
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
const cryptoObj = (
|
||||
globalThis as { crypto?: { getRandomValues?: (b: Uint8Array) => void } }
|
||||
).crypto;
|
||||
if (cryptoObj?.getRandomValues) cryptoObj.getRandomValues(bytes);
|
||||
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
RelayStatusCallback,
|
||||
initiateRelay,
|
||||
} from "./relay-handler.js";
|
||||
import { decodeKeyExchangeURI, DEFAULT_RELAY_URLS } from "./key-exchange.js";
|
||||
import { decodeKeyExchangeURI, DEFAULT_RELAY_PORT } from "./key-exchange.js";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
import { deriveNostrPublicKeyBytes } from "./utilnostr.js";
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ export function initiateWalletRelay(
|
|||
const hostname = decoded.hostname;
|
||||
const protocol = decoded.protocol;
|
||||
const port = decoded.port;
|
||||
const relayUrl = `${protocol}://${hostname}:${port}`;
|
||||
const relayUrl = `${protocol}://${hostname}${port === (protocol === "wss" ? DEFAULT_RELAY_PORT : 80) ? "" : `:${port}`}`;
|
||||
|
||||
const walletPublicKeyNostr = deriveNostrPublicKeyBytes(
|
||||
options.walletPrivateKey,
|
||||
|
|
@ -73,19 +73,6 @@ export function initiateWalletRelay(
|
|||
};
|
||||
|
||||
const relayUrls: string[] = [relayUrl];
|
||||
|
||||
// Add remaining default relays for redundancy when the URI points to a
|
||||
// default relay. Skip when using a custom/private relay — the user chose
|
||||
// that relay deliberately and may not want traffic on public relays.
|
||||
const isDefaultRelay = DEFAULT_RELAY_URLS.includes(relayUrl);
|
||||
if (isDefaultRelay) {
|
||||
for (const defaultUrl of DEFAULT_RELAY_URLS) {
|
||||
if (!relayUrls.includes(defaultUrl)) {
|
||||
relayUrls.push(defaultUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) {
|
||||
for (const explicitUrl of options.explicitRelayUrls) {
|
||||
if (!relayUrls.includes(explicitUrl)) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
{
|
||||
"name": "@wizardconnect/dapp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"description": "Dapp-side integration helpers for WizardConnect",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitlab.com/riftenlabs/lib/wizardconnect",
|
||||
"directory": "packages/dapp"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
|
|
@ -29,6 +24,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,656 +0,0 @@
|
|||
// 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, vi } from "vitest";
|
||||
import { encodeHdPublicKey } from "@bitauth/libauth";
|
||||
import type { PathXpub } from "@wizardconnect/core";
|
||||
import {
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
DisconnectReason,
|
||||
} from "@wizardconnect/core";
|
||||
import type {
|
||||
WalletReadyMessage,
|
||||
SignTransactionRequest,
|
||||
DisconnectMessage,
|
||||
ProtocolMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import { DappConnectionManager } from "./dapp-connection-manager.js";
|
||||
|
||||
// Build a valid xpub string from a deterministic test node.
|
||||
// Uses the secp256k1 generator point (a known valid public key).
|
||||
function makeTestXpub(): string {
|
||||
const node = {
|
||||
chainCode: new Uint8Array(32).fill(0xbb),
|
||||
// secp256k1 generator point (compressed) — always valid
|
||||
publicKey: Uint8Array.from(
|
||||
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
.match(/.{2}/g)!
|
||||
.map((h) => parseInt(h, 16)),
|
||||
),
|
||||
childIndex: 0,
|
||||
depth: 3,
|
||||
parentFingerprint: new Uint8Array(4),
|
||||
};
|
||||
const result = encodeHdPublicKey({ node, network: "mainnet" });
|
||||
if (typeof result === "string")
|
||||
throw new Error("Failed to encode test xpub: " + result);
|
||||
return result.hdPublicKey;
|
||||
}
|
||||
|
||||
describe("DappConnectionManager", () => {
|
||||
describe("getSessionPaths", () => {
|
||||
it("returns empty array before wallet_ready", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
expect(mgr.getSessionPaths()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreSessionPaths", () => {
|
||||
it("restores xpubs so getPubkey works", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const xpub = makeTestXpub();
|
||||
const paths: PathXpub[] = [{ name: "receive", xpub }];
|
||||
|
||||
mgr.restoreSessionPaths(paths);
|
||||
|
||||
expect(mgr.hasPath(0)).toBe(true);
|
||||
const pk = mgr.getPubkey(0, 0n);
|
||||
expect(pk).toBeInstanceOf(Uint8Array);
|
||||
expect(pk!.length).toBe(33);
|
||||
});
|
||||
|
||||
it("makes paths available via getSessionPaths", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const xpub = makeTestXpub();
|
||||
const paths: PathXpub[] = [
|
||||
{ name: "receive", xpub },
|
||||
{ name: "change", xpub },
|
||||
];
|
||||
|
||||
mgr.restoreSessionPaths(paths);
|
||||
|
||||
const stored = mgr.getSessionPaths();
|
||||
expect(stored).toHaveLength(2);
|
||||
expect(stored[0].name).toBe("receive");
|
||||
expect(stored[1].name).toBe("change");
|
||||
});
|
||||
|
||||
it("returns a copy, not a reference", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const xpub = makeTestXpub();
|
||||
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
|
||||
|
||||
const paths = mgr.getSessionPaths();
|
||||
paths.push({ name: "change", xpub });
|
||||
|
||||
expect(mgr.getSessionPaths()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws on invalid xpub strings", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const paths: PathXpub[] = [{ name: "receive", xpub: "not-a-valid-xpub" }];
|
||||
|
||||
expect(() => mgr.restoreSessionPaths(paths)).toThrow("Invalid xpub");
|
||||
});
|
||||
|
||||
it("can derive multiple child indices after restore", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const xpub = makeTestXpub();
|
||||
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
|
||||
|
||||
for (const idx of [0n, 1n, 5n, 10n]) {
|
||||
const pk = mgr.getPubkey(0, idx);
|
||||
expect(pk, `index ${idx}`).toBeInstanceOf(Uint8Array);
|
||||
expect(pk!.length, `index ${idx}`).toBe(33);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not affect paths not in the restore set", () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const xpub = makeTestXpub();
|
||||
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
|
||||
|
||||
expect(mgr.hasPath(0)).toBe(true); // receive = 0
|
||||
expect(mgr.hasPath(1)).toBe(false); // change = 1
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-sends pending sign requests on wallet_ready", () => {
|
||||
function makeTestXpubForSession(): string {
|
||||
const node = {
|
||||
chainCode: new Uint8Array(32).fill(0xbb),
|
||||
publicKey: Uint8Array.from(
|
||||
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
.match(/.{2}/g)!
|
||||
.map((h) => parseInt(h, 16)),
|
||||
),
|
||||
childIndex: 0,
|
||||
depth: 3,
|
||||
parentFingerprint: new Uint8Array(4),
|
||||
};
|
||||
const result = encodeHdPublicKey({ node, network: "mainnet" });
|
||||
if (typeof result === "string") throw new Error(result);
|
||||
return result.hdPublicKey;
|
||||
}
|
||||
|
||||
/** Minimal mock of RelayClient — just enough to drive DappConnectionManager. */
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReady(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: true,
|
||||
session: {
|
||||
[PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] },
|
||||
},
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("re-relays pending sign requests when wallet_ready arrives", async () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const client = makeMockClient();
|
||||
const xpub = makeTestXpubForSession();
|
||||
|
||||
// Attach manager to mock client
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
// Send a sign request (wallet is "connected" but hasn't sent wallet_ready yet)
|
||||
const request: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: client.nextSequence(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
inputPaths: [],
|
||||
transaction: "deadbeef",
|
||||
};
|
||||
const signPromise = mgr.sendSignRequest(request);
|
||||
|
||||
// The initial relay call
|
||||
expect(client.relayed).toHaveLength(2); // dapp_ready + sign request
|
||||
|
||||
// Now simulate wallet_ready arriving (wallet just opened)
|
||||
client.relayed.length = 0;
|
||||
client.emit("message", makeWalletReady(xpub));
|
||||
|
||||
// Wait a tick for the async re-send
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// The sign request should have been re-sent
|
||||
const resent = client.relayed.filter(
|
||||
(m) => m.action === RelayMsgAction.SignTransactionRequest,
|
||||
);
|
||||
expect(resent).toHaveLength(1);
|
||||
expect(resent[0].sequence).toBe(request.sequence);
|
||||
|
||||
// Clean up: resolve the pending promise so it doesn't leak
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: request.sequence,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
transaction: "signed",
|
||||
});
|
||||
await signPromise;
|
||||
});
|
||||
|
||||
it("does not re-send if there are no pending requests", async () => {
|
||||
const mgr = new DappConnectionManager();
|
||||
const client = makeMockClient();
|
||||
const xpub = makeTestXpubForSession();
|
||||
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.relayed.length = 0;
|
||||
|
||||
client.emit("message", makeWalletReady(xpub));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
// Only dapp_ready should have been sent (reactive), no sign requests
|
||||
const signMsgs = client.relayed.filter(
|
||||
(m) => m.action === RelayMsgAction.SignTransactionRequest,
|
||||
);
|
||||
expect(signMsgs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keepalive reconnect grace timer", () => {
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDisconnectMsg(): DisconnectMessage {
|
||||
return {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: true,
|
||||
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("suppresses disconnect when wallet_ready arrives within the grace window", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
// Wallet keepalive watchdog fires UserDisconnect
|
||||
client.emit("message", makeDisconnectMsg());
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
|
||||
// Wallet reconnects and sends wallet_ready before the window expires
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
vi.advanceTimersByTime(26_000); // past where the original 30s timer would have fired
|
||||
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fires disconnect after the grace window if wallet does not reconnect", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: Array<{
|
||||
reason: DisconnectReason;
|
||||
message: string | undefined;
|
||||
}> = [];
|
||||
mgr.on("disconnect", (reason, message) =>
|
||||
disconnectEvents.push({ reason, message }),
|
||||
);
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
client.emit("message", makeDisconnectMsg());
|
||||
|
||||
vi.advanceTimersByTime(7_999);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(disconnectEvents).toHaveLength(1);
|
||||
expect(disconnectEvents[0].reason).toBe(
|
||||
DisconnectReason.UserDisconnect,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ping interval", () => {
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: true,
|
||||
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("sends a Ping after 10s following wallet_ready", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops sending pings once the grace timer fires on disconnect", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Grace timer fires at 30s and stops the ping interval
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(disconnectEvents).toHaveLength(1);
|
||||
|
||||
// No further pings after the interval was stopped
|
||||
client.relayed.length = 0;
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets the ping interval when wallet reconnects", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
// First wallet_ready — ping interval starts (would fire at t=10s)
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
vi.advanceTimersByTime(5_000); // t=5s, no ping yet
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
|
||||
// Wallet reconnects — second wallet_ready resets the interval
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
client.relayed.length = 0;
|
||||
|
||||
// Old interval would have fired at t=15s (10s from now); new fires at t=25s
|
||||
vi.advanceTimersByTime(9_000); // t=14s — still no ping
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1_000); // t=15s — new interval fires
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("enters reconnecting state after 75s with no pong or wallet_ready (does not disconnect)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
const reconnectingEvents: number[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.on("reconnecting", () => reconnectingEvents.push(1));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// Still alive just before the threshold
|
||||
vi.advanceTimersByTime(74_999);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
expect(reconnectingEvents).toHaveLength(0);
|
||||
|
||||
// Next interval check pushes past 75s — liveness timeout fires reconnecting, not disconnect
|
||||
vi.advanceTimersByTime(5_001); // advances to t=80s (next 10s interval)
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
expect(reconnectingEvents).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers to connected when wallet_ready arrives after liveness timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
const walletReadyEvents: number[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.on("walletready", () => walletReadyEvents.push(1));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// Liveness timeout fires at t=80s
|
||||
vi.advanceTimersByTime(80_001);
|
||||
|
||||
// Wallet wakes up and sends wallet_ready — dapp should resume connected
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
expect(walletReadyEvents).toHaveLength(2); // initial + recovery
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
|
||||
// Ping interval restarts after recovery — next ping at t=90s
|
||||
client.relayed.length = 0;
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not disconnect when wallet_ready resets the liveness timer", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// Simulate wallet_ready (keepalive reconnect) at t=60s, resetting the timer
|
||||
vi.advanceTimersByTime(60_000);
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// 74s after the reset — still below 75s threshold
|
||||
vi.advanceTimersByTime(74_999);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dapp_ready ordering on reconnect", () => {
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: false,
|
||||
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("sends dapp_ready before re-sending pending sign requests", async () => {
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const xpub = makeTestXpub();
|
||||
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
const request: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: client.nextSequence(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
inputPaths: [],
|
||||
transaction: "deadbeef",
|
||||
};
|
||||
const signPromise = mgr.sendSignRequest(request);
|
||||
client.relayed.length = 0;
|
||||
|
||||
// wallet_ready with dapp_discovered: false — dapp must await dapp_ready
|
||||
// before re-sending pending sign requests
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const actions = client.relayed.map((m) => m.action);
|
||||
const dappReadyIdx = actions.indexOf(RelayMsgAction.DappReady);
|
||||
const signReqIdx = actions.indexOf(RelayMsgAction.SignTransactionRequest);
|
||||
|
||||
expect(dappReadyIdx).toBeGreaterThan(-1);
|
||||
expect(signReqIdx).toBeGreaterThan(-1);
|
||||
expect(dappReadyIdx).toBeLessThan(signReqIdx);
|
||||
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: request.sequence,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
signedTransaction: "signed",
|
||||
});
|
||||
await signPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,32 +16,13 @@ import {
|
|||
SignTransactionRequest,
|
||||
SignTransactionResponse,
|
||||
SignCancelMessage,
|
||||
PingMessage,
|
||||
ProtocolMessage,
|
||||
PROTOCOL_NAME,
|
||||
PathName,
|
||||
childIndexOfPathName,
|
||||
isHdwalletv1Session,
|
||||
binToHex,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
} 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. */
|
||||
|
|
@ -52,8 +33,6 @@ export interface DappConnectionManagerEvents {
|
|||
messagereceived: [msg: ProtocolMessage];
|
||||
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
|
||||
disconnect: [reason: DisconnectReason, message: string | undefined];
|
||||
/** Fired when the relay connection drops and auto-reconnect begins. */
|
||||
reconnecting: [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,111 +61,26 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
|
||||
|
||||
private walletDiscovered = false;
|
||||
private disconnectGraceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
|
||||
private lastPongTime: number = 0;
|
||||
private sessionPaths: PathXpub[] = [];
|
||||
private pendingSignatureRequests = new Map<
|
||||
number,
|
||||
{
|
||||
request: SignTransactionRequest;
|
||||
resolve: (r: SignTransactionResponse) => void;
|
||||
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
|
||||
|
|
@ -207,14 +101,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
|
||||
if (status.status === "connected" && this.conn) {
|
||||
this.onConnected();
|
||||
} else if (
|
||||
status.status === "reconnecting" ||
|
||||
status.status === "disconnected"
|
||||
) {
|
||||
this.stopPingInterval();
|
||||
if (status.status === "reconnecting") {
|
||||
this.emit("reconnecting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -242,11 +128,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
||||
|
||||
return new Promise<SignTransactionResponse>((resolve, reject) => {
|
||||
this.pendingSignatureRequests.set(request.sequence, {
|
||||
request,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
this.pendingSignatureRequests.set(request.sequence, { resolve, reject });
|
||||
|
||||
this.conn!.relay(request)
|
||||
.then(() => {
|
||||
|
|
@ -297,57 +179,6 @@ 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.
|
||||
|
||||
|
|
@ -368,93 +199,22 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
return this.pubkeyState.getXpubNode(childIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw PathXpub entries received from the wallet.
|
||||
* Available after wallet_ready is received.
|
||||
*/
|
||||
getSessionPaths(): PathXpub[] {
|
||||
return [...this.sessionPaths];
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session paths from a previous session (e.g. from localStorage).
|
||||
* Decodes xpub strings and populates pubkeyState so getPubkey() works
|
||||
* without waiting for wallet_ready.
|
||||
*/
|
||||
restoreSessionPaths(paths: PathXpub[]): void {
|
||||
this.sessionPaths = [...paths];
|
||||
for (const pathInfo of paths) {
|
||||
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
||||
if (typeof decoded === "string") {
|
||||
throw new Error(
|
||||
`[wizardconnect/dapp] Invalid xpub for path "${pathInfo.name}": ${decoded}`,
|
||||
);
|
||||
}
|
||||
const ci = childIndexOfPathName(pathInfo.name);
|
||||
if (ci !== undefined) {
|
||||
this.pubkeyState.setXpubNode(ci, decoded.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private protocol handling -------------------------------------------
|
||||
|
||||
private onConnected(): void {
|
||||
(async () => {
|
||||
// Wait until key exchange is complete before sending dapp_ready
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (this.conn && !this.conn.isKeyExchangeComplete()) {
|
||||
if (Date.now() >= deadline) {
|
||||
console.error("[wizardconnect/dapp] Key exchange timed out");
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!this.conn) return;
|
||||
await this.pushDappReady();
|
||||
if (this.conn) {
|
||||
await this.pushDappReady();
|
||||
}
|
||||
})().catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error in onConnected:", e),
|
||||
);
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
this.stopPingInterval();
|
||||
// Treat connection as live at the moment the session is established.
|
||||
this.lastPongTime = Date.now();
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (!this.conn || !this.walletDiscovered) return;
|
||||
|
||||
// If the wallet hasn't responded (pong or wallet_ready) within 75s,
|
||||
// it silently dropped the session — trigger reconnect.
|
||||
if (Date.now() - this.lastPongTime > 75_000) {
|
||||
this.stopPingInterval();
|
||||
this.emit("reconnecting");
|
||||
return;
|
||||
}
|
||||
|
||||
const ping: PingMessage = {
|
||||
action: RelayMsgAction.Ping,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.conn.relay(ping).catch(() => {});
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
private stopPingInterval(): void {
|
||||
if (this.pingInterval !== null) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopPingInterval();
|
||||
if (this.disconnectGraceTimer !== null) {
|
||||
clearTimeout(this.disconnectGraceTimer);
|
||||
this.disconnectGraceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async pushDappReady(): Promise<void> {
|
||||
if (!this.conn) return;
|
||||
|
||||
|
|
@ -468,10 +228,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
this.protocol && { selected_protocol: this.protocol }),
|
||||
...(this.dappName !== undefined && { dapp_name: this.dappName }),
|
||||
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
|
||||
// Transport-level: advertise chunking so the wallet can send large
|
||||
// SignTransactionResponses (signed tx hex can reach ~2 MB) that exceed
|
||||
// NIP-44's plaintext ceiling.
|
||||
extensions: { chunk: chunkExtensionAdvertisement() },
|
||||
};
|
||||
|
||||
await this.conn.relay(msg);
|
||||
|
|
@ -482,9 +238,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
this.emit("messagereceived", msg);
|
||||
switch (msg.action) {
|
||||
case RelayMsgAction.WalletReady:
|
||||
this.handleWalletReady(msg as WalletReadyMessage).catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error handling wallet_ready:", e),
|
||||
);
|
||||
this.handleWalletReady(msg as WalletReadyMessage);
|
||||
break;
|
||||
case RelayMsgAction.SignTransactionResponse:
|
||||
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
||||
|
|
@ -492,42 +246,22 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
case RelayMsgAction.Disconnect:
|
||||
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
||||
break;
|
||||
case RelayMsgAction.Pong:
|
||||
this.lastPongTime = Date.now();
|
||||
break;
|
||||
case RelayMsgAction.DappReady:
|
||||
// Not expected on dapp side — silently ignore
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
console.warn(
|
||||
"[wizardconnect/dapp] Unknown message action:",
|
||||
msg.action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteDisconnect(msg: DisconnectMessage): void {
|
||||
// Give the wallet a short window to reconnect before propagating the
|
||||
// disconnect. Wallets that use a keepalive watchdog send UserDisconnect
|
||||
// and immediately reconnect; without this grace period the dapp would
|
||||
// tear down the session before the wallet_ready arrives.
|
||||
// 8s covers worst-case relay reconnect (WebSocket + 5s EOSE timeout + latency)
|
||||
// while being fast enough that a real explicit disconnect is felt promptly.
|
||||
if (this.disconnectGraceTimer !== null) {
|
||||
clearTimeout(this.disconnectGraceTimer);
|
||||
}
|
||||
this.disconnectGraceTimer = setTimeout(() => {
|
||||
this.disconnectGraceTimer = null;
|
||||
this.stopPingInterval();
|
||||
this.emit("disconnect", msg.reason, msg.message);
|
||||
}, 8_000);
|
||||
this.emit("disconnect", msg.reason, msg.message);
|
||||
}
|
||||
|
||||
private async handleWalletReady(msg: WalletReadyMessage): Promise<void> {
|
||||
if (this.disconnectGraceTimer !== null) {
|
||||
clearTimeout(this.disconnectGraceTimer);
|
||||
this.disconnectGraceTimer = null;
|
||||
}
|
||||
// Treat wallet_ready as a liveness signal — resets the pong stale timer.
|
||||
// This covers keepalive reconnects where the wallet reconnects instead of ponging.
|
||||
this.lastPongTime = Date.now();
|
||||
private handleWalletReady(msg: WalletReadyMessage): void {
|
||||
this.walletDiscovered = true;
|
||||
this.walletName = msg.wallet_name;
|
||||
this.walletIcon = msg.wallet_icon;
|
||||
|
|
@ -545,7 +279,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.conn?.relay(disconnectMsg).catch(() => {});
|
||||
this.stopPingInterval();
|
||||
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
|
||||
return;
|
||||
}
|
||||
|
|
@ -561,17 +294,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
return;
|
||||
}
|
||||
|
||||
// Transport-level capability: if the wallet advertises chunking, enable
|
||||
// chunked requests. Re-applied on every wallet_ready (cheap and idempotent),
|
||||
// so reconnects pick up capability changes.
|
||||
if (this.conn) {
|
||||
this.conn.setPeerCapabilities({
|
||||
chunk: peerSupportsChunk(msg.extensions),
|
||||
});
|
||||
}
|
||||
|
||||
// Store raw paths for getSessionPaths() and xpub nodes for derivation
|
||||
this.sessionPaths = [...sessionData.paths];
|
||||
// Store xpub nodes — no eager derivation; consumer drives it
|
||||
for (const pathInfo of sessionData.paths) {
|
||||
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
||||
if (typeof decoded === "string") {
|
||||
|
|
@ -582,54 +305,17 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
);
|
||||
continue;
|
||||
}
|
||||
const ci = childIndexOfPathName(pathInfo.name);
|
||||
if (ci !== undefined) {
|
||||
this.pubkeyState.setXpubNode(ci, decoded.node);
|
||||
}
|
||||
const ci = childIndexOfPathName(pathInfo.name as PathName);
|
||||
this.pubkeyState.setXpubNode(ci, decoded.node);
|
||||
}
|
||||
|
||||
// Send dapp_ready first so the wallet's session state is confirmed before
|
||||
// any sign requests arrive — await ensures correct ordering on the wire.
|
||||
if (!msg.dapp_discovered) {
|
||||
await this.pushDappReady().catch((e) =>
|
||||
this.pushDappReady().catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
|
||||
);
|
||||
}
|
||||
|
||||
this.emit("walletready", msg);
|
||||
|
||||
// Re-send any pending sign requests with a fresh timestamp so the wallet
|
||||
// doesn't filter them as already-processed (the wallet timestamps messages
|
||||
// at receive time and ignores anything older than its last disconnect).
|
||||
if (this.pendingSignatureRequests.size > 0) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const [, entry] of this.pendingSignatureRequests) {
|
||||
const refreshed = { ...entry.request, time: now };
|
||||
this.conn!.relay(refreshed)
|
||||
.then(() => this.emit("messagesent", refreshed))
|
||||
.catch((err) => {
|
||||
this.pendingSignatureRequests.delete(entry.request.sequence);
|
||||
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
// Start keepalive pings now that the session is fully established.
|
||||
this.startPingInterval();
|
||||
}
|
||||
|
||||
private handleSignTransactionResponse(
|
||||
|
|
|
|||
|
|
@ -3,15 +3,5 @@
|
|||
// 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,
|
||||
DappSessionOptions,
|
||||
} from "./dapp-connection-manager.js";
|
||||
export type { DappConnectionManagerEvents } 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";
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
# @wizardconnect/react
|
||||
|
||||
React components and hooks for integrating WizardConnect into dapps.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @wizardconnect/react @wizardconnect/core @wizardconnect/dapp
|
||||
```
|
||||
|
||||
React 18+ is required as a peer dependency.
|
||||
|
||||
## Components
|
||||
|
||||
### WizardConnectQRDialog
|
||||
|
||||
A portal-based modal dialog that displays a WizardConnect QR code for wallet pairing. Uses inline styles for framework independence (no Tailwind or CSS framework required).
|
||||
|
||||
```tsx
|
||||
import { WizardConnectQRDialog } from "@wizardconnect/react";
|
||||
|
||||
<WizardConnectQRDialog
|
||||
show={showDialog}
|
||||
onClose={() => setShowDialog(false)}
|
||||
uri={connection.uri}
|
||||
qrUri={connection.qrUri}
|
||||
logoUrl="/my-logo.png"
|
||||
theme={{
|
||||
dialogBackground: "#1e293b",
|
||||
headerBackground: "#1e293b",
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ----------------------- | ------------------------------------ | --------------------------------------------------- |
|
||||
| `show` | `boolean` | _required_ | Whether the dialog is visible |
|
||||
| `onClose` | `() => void` | _required_ | Called when the user clicks close or the backdrop |
|
||||
| `uri` | `string` | _required_ | Human-readable URI to display (`wiz://...`) |
|
||||
| `qrUri` | `string` | _required_ | Alphanumeric-safe URI for QR encoding (`WIZ://...`) |
|
||||
| `onCopy` | `(uri: string) => void` | `navigator.clipboard.writeText` | Called when copy button is clicked |
|
||||
| `theme` | `WizardConnectQRTheme` | dark theme defaults | Color overrides |
|
||||
| `title` | `string` | `"WizardConnect"` | Dialog title |
|
||||
| `subtitle` | `string` | `"Scan with your wallet to connect"` | Subtitle text |
|
||||
| `logoUrl` | `string` | none | Logo for the header |
|
||||
| `className` | `string` | none | Additional CSS class on the outermost container |
|
||||
|
||||
### AlphanumericQRCode
|
||||
|
||||
A standalone canvas-based QR code renderer. Uses Alphanumeric mode with error correction level H (30% recovery) to tolerate a center logo overlay.
|
||||
|
||||
```tsx
|
||||
import { AlphanumericQRCode } from "@wizardconnect/react";
|
||||
|
||||
<AlphanumericQRCode
|
||||
value="WIZ://..."
|
||||
size={280}
|
||||
foreground="#1e2a4a"
|
||||
background="#ffffff"
|
||||
logoUrl="/logo.png"
|
||||
/>;
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useWizardConnect
|
||||
|
||||
Encapsulates the full WizardConnect relay lifecycle: relay initiation, `DappConnectionManager` management, key exchange events, session persistence, and auto-reconnect.
|
||||
|
||||
```tsx
|
||||
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
|
||||
|
||||
function ConnectButton() {
|
||||
const {
|
||||
state, // "idle" | "connecting" | "connected" | "disconnected"
|
||||
manager, // DappConnectionManager (null until connect())
|
||||
uri, // connection URI (null until connect())
|
||||
qrUri, // QR-safe URI (null until connect())
|
||||
walletName, // wallet name (null until walletready)
|
||||
walletIcon, // wallet icon (null until walletready)
|
||||
connect, // () => boolean — initiate a new connection
|
||||
disconnect, // () => Promise<void> — disconnect and clean up
|
||||
error, // string | null — error message
|
||||
} = useWizardConnect({
|
||||
dappName: "My Dapp",
|
||||
dappIcon: "https://example.com/icon.png",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{state === "idle" && <button onClick={connect}>Connect</button>}
|
||||
{state === "connected" && <span>Connected to {walletName}</span>}
|
||||
|
||||
{uri && qrUri && (
|
||||
<WizardConnectQRDialog
|
||||
show={state === "connecting"}
|
||||
onClose={disconnect}
|
||||
uri={uri}
|
||||
qrUri={qrUri}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | ---------- | ------------------------- | ------------------------------------------ |
|
||||
| `dappName` | `string` | none | Display name sent in `dapp_ready` |
|
||||
| `dappIcon` | `string` | none | Icon URL sent in `dapp_ready` |
|
||||
| `relayUrls` | `string[]` | default relay | Explicit relay WebSocket URLs |
|
||||
| `sessionKey` | `string` | `"wizardconnect-session"` | localStorage key for session persistence |
|
||||
| `persistSession` | `boolean` | `true` | Whether to save session for auto-reconnect |
|
||||
|
||||
**Using the `manager`:**
|
||||
|
||||
After `state` becomes `"connected"`, use `manager` to build your app-specific wallet adapter. The manager provides:
|
||||
|
||||
- `getPubkey(childIndex, addressIndex)` — derive pubkeys from xpubs
|
||||
- `sendSignRequest(request)` — request transaction signatures
|
||||
- `sendSignCancel(sequence)` — cancel an in-flight sign request
|
||||
- `on("walletready", callback)` — listen for wallet handshake completion
|
||||
|
||||
See the [`@wizardconnect/dapp` documentation](../../docs/dapp.md) for the full `DappConnectionManager` API.
|
||||
|
||||
## Theme customization
|
||||
|
||||
All colors in `WizardConnectQRDialog` can be overridden via the `theme` prop:
|
||||
|
||||
```tsx
|
||||
const myTheme: WizardConnectQRTheme = {
|
||||
backdropColor: "rgba(0,0,0,0.5)",
|
||||
dialogBackground: "#1a1f2e",
|
||||
headerBackground: "#1a1f2e",
|
||||
titleColor: "#ffffff",
|
||||
subtitleColor: "#9ca3af",
|
||||
qrForeground: "#1e2a4a",
|
||||
qrBackground: "#ffffff",
|
||||
uriRowBackground: "rgba(31,41,55,0.6)",
|
||||
uriTextColor: "#9ca3af",
|
||||
borderColor: "#374151",
|
||||
closeButtonColor: "#9ca3af",
|
||||
copyButtonColor: "#9ca3af",
|
||||
logoUrl: "/my-qr-logo.png",
|
||||
qrSize: 280,
|
||||
};
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
LGPL-3.0-or-later. See [LICENSE](../../LICENSE).
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "React components and hooks for WizardConnect dapp integration",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitlab.com/riftenlabs/lib/wizardconnect.git",
|
||||
"directory": "packages/react"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
||||
"lint:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:prettier": "prettier --ignore-path ../../.gitignore . --write",
|
||||
"fix:eslint": "npm run lint:eslint -- --fix"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"@wizardconnect/dapp": "*",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"happy-dom": "^20.8.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
// 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 { useRef, useEffect } from "react";
|
||||
import qrGenerator from "qrcode-generator";
|
||||
import { WIZARDCONNECT_LOGO } from "./logo.js";
|
||||
import type { AlphanumericQRCodeProps } from "../types.js";
|
||||
|
||||
const DEFAULT_SIZE = 280;
|
||||
const DEFAULT_QUIET_ZONE = 4;
|
||||
const DEFAULT_FG = "#1e2a4a";
|
||||
const DEFAULT_BG = "#ffffff";
|
||||
|
||||
/**
|
||||
* Renders a QR code on a canvas using explicit Alphanumeric mode.
|
||||
* Optionally overlays a logo in the center (uses error correction H).
|
||||
*/
|
||||
export function AlphanumericQRCode({
|
||||
value,
|
||||
size = DEFAULT_SIZE,
|
||||
foreground = DEFAULT_FG,
|
||||
background = DEFAULT_BG,
|
||||
quietZone = DEFAULT_QUIET_ZONE,
|
||||
}: AlphanumericQRCodeProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Use error correction H (30% recovery) to tolerate the center logo
|
||||
const qr = qrGenerator(0, "H");
|
||||
qr.addData(value, "Alphanumeric");
|
||||
qr.make();
|
||||
|
||||
const moduleCount = qr.getModuleCount();
|
||||
const cellSize = size / moduleCount;
|
||||
const canvasSize = size + 2 * quietZone;
|
||||
const scale =
|
||||
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
||||
|
||||
canvas.width = canvasSize * scale;
|
||||
canvas.height = canvasSize * scale;
|
||||
canvas.style.width = `${canvasSize}px`;
|
||||
canvas.style.height = `${canvasSize}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, canvasSize, canvasSize);
|
||||
|
||||
// Draw QR modules
|
||||
ctx.fillStyle = foreground;
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
if (qr.isDark(row, col)) {
|
||||
ctx.fillRect(
|
||||
Math.round(col * cellSize) + quietZone,
|
||||
Math.round(row * cellSize) + quietZone,
|
||||
Math.ceil(cellSize),
|
||||
Math.ceil(cellSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay WizardConnect logo in the center
|
||||
{
|
||||
const logo = new Image();
|
||||
logo.onload = () => {
|
||||
const logoSize = size * 0.22;
|
||||
const logoPadding = 4;
|
||||
const dx = (canvasSize - logoSize) / 2;
|
||||
const dy = (canvasSize - logoSize) / 2;
|
||||
|
||||
// Clear rectangular area behind logo
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(
|
||||
dx - logoPadding,
|
||||
dy - logoPadding,
|
||||
logoSize + logoPadding * 2,
|
||||
logoSize + logoPadding * 2,
|
||||
);
|
||||
|
||||
ctx.drawImage(logo, dx, dy, logoSize, logoSize);
|
||||
};
|
||||
logo.src = WIZARDCONNECT_LOGO;
|
||||
}
|
||||
}, [value, size, foreground, background, quietZone]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ height: "auto", maxWidth: `${size}px`, width: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
// 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, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { WizardConnectQRDialog } from "./WizardConnectQRDialog.js";
|
||||
|
||||
// Mock the QR component to avoid canvas issues in happy-dom
|
||||
vi.mock("./AlphanumericQRCode.js", () => ({
|
||||
AlphanumericQRCode: (props: { value: string }) =>
|
||||
createElement("div", {
|
||||
"data-testid": "qr-code",
|
||||
"data-value": props.value,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("WizardConnectQRDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
// Clean up portals
|
||||
const portals = document.querySelectorAll("[style*='position: fixed']");
|
||||
portals.forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
function render(
|
||||
props: Partial<Parameters<typeof WizardConnectQRDialog>[0]> = {},
|
||||
) {
|
||||
const defaultProps = {
|
||||
show: true,
|
||||
onClose: () => {},
|
||||
uri: "wiz://test",
|
||||
qrUri: "WIZ://TEST",
|
||||
};
|
||||
root.render(
|
||||
createElement(WizardConnectQRDialog, { ...defaultProps, ...props }),
|
||||
);
|
||||
// Flush synchronous React work
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
it("renders nothing when show is false", async () => {
|
||||
await render({ show: false });
|
||||
|
||||
const backdrops = document.querySelectorAll("[style*='position: fixed']");
|
||||
expect(backdrops.length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a portal modal when show is true", async () => {
|
||||
await render({ show: true });
|
||||
|
||||
const backdrops = document.querySelectorAll("[style*='position: fixed']");
|
||||
expect(backdrops.length).toBe(1);
|
||||
});
|
||||
|
||||
it("displays the default title", async () => {
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain("WizardConnect");
|
||||
});
|
||||
|
||||
it("displays a custom title", async () => {
|
||||
await render({ title: "My Custom Title" });
|
||||
|
||||
expect(document.body.textContent).toContain("My Custom Title");
|
||||
});
|
||||
|
||||
it("displays the URI text", async () => {
|
||||
await render({ uri: "wiz://my-unique-uri" });
|
||||
|
||||
expect(document.body.textContent).toContain("wiz://my-unique-uri");
|
||||
});
|
||||
|
||||
it("displays the default subtitle", async () => {
|
||||
await render();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"Scan with your wallet to connect",
|
||||
);
|
||||
});
|
||||
|
||||
it("displays a custom subtitle", async () => {
|
||||
await render({ subtitle: "Custom scan instructions" });
|
||||
|
||||
expect(document.body.textContent).toContain("Custom scan instructions");
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
await render({ onClose });
|
||||
|
||||
const closeButton = document.querySelector(
|
||||
"button[aria-label='Close']",
|
||||
) as HTMLButtonElement;
|
||||
expect(closeButton).not.toBeNull();
|
||||
closeButton.click();
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onCopy with the URI when copy button is clicked", async () => {
|
||||
const onCopy = vi.fn();
|
||||
await render({ uri: "wiz://copy-test", onCopy });
|
||||
|
||||
const copyButton = document.querySelector(
|
||||
"button[aria-label='Copy URI']",
|
||||
) as HTMLButtonElement;
|
||||
expect(copyButton).not.toBeNull();
|
||||
copyButton.click();
|
||||
|
||||
expect(onCopy).toHaveBeenCalledOnce();
|
||||
expect(onCopy).toHaveBeenCalledWith("wiz://copy-test");
|
||||
});
|
||||
|
||||
it("applies custom theme colors", async () => {
|
||||
await render({
|
||||
theme: {
|
||||
dialogBackground: "#ff0000",
|
||||
headerBackground: "#00ff00",
|
||||
},
|
||||
});
|
||||
|
||||
const elements = document.querySelectorAll("[style*='background-color']");
|
||||
const styles = Array.from(elements).map(
|
||||
(el) => (el as HTMLElement).style.backgroundColor,
|
||||
);
|
||||
expect(styles).toContain("#00ff00");
|
||||
expect(styles).toContain("#ff0000");
|
||||
});
|
||||
|
||||
it("always renders the WizardConnect logo in the header", async () => {
|
||||
await render();
|
||||
|
||||
const logo = document.querySelector("img") as HTMLImageElement;
|
||||
expect(logo).not.toBeNull();
|
||||
expect(logo.src).toContain("data:image/png;base64,");
|
||||
});
|
||||
|
||||
it("passes qrUri to the QR code component", async () => {
|
||||
await render({ qrUri: "WIZ://MY-QR-VALUE" });
|
||||
|
||||
const qrCode = document.querySelector("[data-testid='qr-code']");
|
||||
expect(qrCode).not.toBeNull();
|
||||
expect(qrCode?.getAttribute("data-value")).toBe("WIZ://MY-QR-VALUE");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
// 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 React, { useCallback, useState, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlphanumericQRCode } from "./AlphanumericQRCode.js";
|
||||
import { WIZARDCONNECT_LOGO } from "./logo.js";
|
||||
import type {
|
||||
WizardConnectQRDialogProps,
|
||||
WizardConnectQRTheme,
|
||||
} from "../types.js";
|
||||
|
||||
const defaults: Required<
|
||||
Pick<
|
||||
WizardConnectQRTheme,
|
||||
| "backdropColor"
|
||||
| "dialogBackground"
|
||||
| "headerBackground"
|
||||
| "titleColor"
|
||||
| "subtitleColor"
|
||||
| "qrForeground"
|
||||
| "qrBackground"
|
||||
| "uriRowBackground"
|
||||
| "uriTextColor"
|
||||
| "borderColor"
|
||||
| "closeButtonColor"
|
||||
| "copyButtonColor"
|
||||
| "qrSize"
|
||||
>
|
||||
> = {
|
||||
backdropColor: "rgba(0,0,0,0.5)",
|
||||
dialogBackground: "#1a1f2e",
|
||||
headerBackground: "#1a1f2e",
|
||||
titleColor: "#ffffff",
|
||||
subtitleColor: "#9ca3af",
|
||||
qrForeground: "#1e2a4a",
|
||||
qrBackground: "#ffffff",
|
||||
uriRowBackground: "rgba(31,41,55,0.6)",
|
||||
uriTextColor: "#9ca3af",
|
||||
borderColor: "#374151",
|
||||
closeButtonColor: "#9ca3af",
|
||||
copyButtonColor: "#9ca3af",
|
||||
qrSize: 280,
|
||||
};
|
||||
|
||||
function CloseIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M5 5L15 15M15 5L5 15"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portal-based modal dialog that displays a WizardConnect QR code
|
||||
* for wallet pairing. Framework-independent (inline styles, no Tailwind).
|
||||
*/
|
||||
export function WizardConnectQRDialog({
|
||||
show,
|
||||
onClose,
|
||||
uri,
|
||||
qrUri,
|
||||
onCopy,
|
||||
theme,
|
||||
className,
|
||||
subtitle = "Scan with your wallet to connect",
|
||||
title = "WizardConnect",
|
||||
}: WizardConnectQRDialogProps) {
|
||||
const t = { ...defaults, ...theme };
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (onCopy) {
|
||||
onCopy(uri);
|
||||
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(uri).catch(() => {});
|
||||
}
|
||||
setCopied(true);
|
||||
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
|
||||
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
}, [uri, onCopy]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 40,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: "16px",
|
||||
backgroundColor: t.backdropColor,
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "384px",
|
||||
borderRadius: "16px",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.5)",
|
||||
border: `1px solid ${t.borderColor}`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
backgroundColor: t.headerBackground,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<img
|
||||
src={WIZARDCONNECT_LOGO}
|
||||
alt=""
|
||||
style={{ width: "28px", height: "28px" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: t.titleColor,
|
||||
fontWeight: 600,
|
||||
fontSize: "18px",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon color={t.closeButtonColor} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: t.dialogBackground,
|
||||
padding: "0 20px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: t.subtitleColor, fontSize: "14px", margin: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
{/* QR container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: t.qrBackground,
|
||||
padding: "12px",
|
||||
borderRadius: "12px",
|
||||
}}
|
||||
>
|
||||
<AlphanumericQRCode
|
||||
value={qrUri}
|
||||
size={t.qrSize}
|
||||
foreground={t.qrForeground}
|
||||
background={t.qrBackground}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Copy URI row — entire row is clickable */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "10px 12px",
|
||||
backgroundColor: t.uriRowBackground,
|
||||
borderRadius: "8px",
|
||||
border: `1px solid ${t.borderColor}`,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
transition: "border-color 0.15s",
|
||||
}}
|
||||
aria-label="Copy URI"
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: t.uriTextColor,
|
||||
fontSize: "12px",
|
||||
margin: 0,
|
||||
flex: 1,
|
||||
wordBreak: "break-all",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
}}
|
||||
>
|
||||
{uri}
|
||||
</p>
|
||||
{copied ? (
|
||||
<span
|
||||
style={{
|
||||
color: "#0307fe",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Copied!
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<CopyIcon color={t.copyButtonColor} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,368 +0,0 @@
|
|||
// 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, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { UseWizardConnectResult } from "../types.js";
|
||||
|
||||
// Mock @wizardconnect/core before importing the hook
|
||||
vi.mock("@wizardconnect/core", () => {
|
||||
const EventEmitter = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}));
|
||||
|
||||
return {
|
||||
initiateDappRelay: vi.fn(() => ({
|
||||
client: {},
|
||||
uri: "wiz://test-uri",
|
||||
qrUri: "WIZ://TEST-URI",
|
||||
credentials: {
|
||||
privateKey: "a".repeat(64),
|
||||
publicKey: "b".repeat(64),
|
||||
secret: "c".repeat(16),
|
||||
},
|
||||
events: new (EventEmitter as unknown as {
|
||||
new (): {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
})(),
|
||||
cleanup: vi.fn(),
|
||||
})),
|
||||
binToHex: vi.fn((bytes: Uint8Array) =>
|
||||
Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(""),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
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(),
|
||||
walletName: null,
|
||||
walletIcon: null,
|
||||
updateConnection: vi.fn(),
|
||||
sendDisconnect: vi.fn(() => Promise.resolve()),
|
||||
getSessionPaths: vi.fn(() => []),
|
||||
restoreSessionPaths: vi.fn(),
|
||||
attachRelay: vi.fn(),
|
||||
loadStoredSession: vi.fn(() => null),
|
||||
clearStoredSession: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import AFTER mocks are set up
|
||||
const { useWizardConnect } = await import("./useWizardConnect.js");
|
||||
const { initiateDappRelay } = await import("@wizardconnect/core");
|
||||
|
||||
// Mock localStorage since happy-dom's implementation is incomplete
|
||||
const storage = new Map<string, string>();
|
||||
const mockLocalStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
clear: () => storage.clear(),
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
key: (_index: number) => null,
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: mockLocalStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("useWizardConnect", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const SESSION_KEY = "wc-test-session";
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
storage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
storage.clear();
|
||||
});
|
||||
|
||||
/** Renders the hook inside a test component, returning a ref to the latest result. */
|
||||
async function renderHook(
|
||||
options?: Parameters<typeof useWizardConnect>[0],
|
||||
): Promise<{ result: { current: UseWizardConnectResult } }> {
|
||||
const result: { current: UseWizardConnectResult } = {
|
||||
current: null as unknown as UseWizardConnectResult,
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
const hookResult = useWizardConnect({
|
||||
sessionKey: SESSION_KEY,
|
||||
...options,
|
||||
});
|
||||
result.current = hookResult;
|
||||
return null;
|
||||
}
|
||||
|
||||
root.render(createElement(TestComponent));
|
||||
// Wait for React to process the render
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
return { result };
|
||||
}
|
||||
|
||||
it("starts in idle state", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
expect(result.current.state).toBe("idle");
|
||||
expect(result.current.manager).toBeNull();
|
||||
expect(result.current.uri).toBeNull();
|
||||
expect(result.current.qrUri).toBeNull();
|
||||
expect(result.current.walletName).toBeNull();
|
||||
expect(result.current.walletIcon).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("transitions to connecting on connect()", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(result.current.state).toBe("connecting");
|
||||
expect(result.current.manager).not.toBeNull();
|
||||
expect(result.current.uri).toBe("wiz://test-uri");
|
||||
expect(result.current.qrUri).toBe("WIZ://TEST-URI");
|
||||
});
|
||||
|
||||
it("connect() calls initiateDappRelay", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("connect() returns false when already connecting", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
const firstResult = result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
const secondResult = result.current.connect();
|
||||
|
||||
expect(firstResult).toBe(true);
|
||||
expect(secondResult).toBe(false);
|
||||
});
|
||||
|
||||
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 mgrInstance = DappConnectionManager.mock.results[0]?.value;
|
||||
expect(mgrInstance.attachRelay).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not save credentials when persistSession is false", async () => {
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
|
||||
|
||||
await result.current.disconnect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(result.current.state).toBe("idle");
|
||||
expect(result.current.manager).toBeNull();
|
||||
expect(result.current.uri).toBeNull();
|
||||
expect(result.current.qrUri).toBeNull();
|
||||
expect(mgrInstance.clearStoredSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
}),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: true });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).toHaveBeenCalledOnce();
|
||||
|
||||
// Should pass existing credentials
|
||||
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
|
||||
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 () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
}),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: true });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-reconnect when persistSession is false", async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
}),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: false });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(initiateDappRelay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes dappName and dappIcon to DappConnectionManager", async () => {
|
||||
const { DappConnectionManager } = vi.mocked(
|
||||
await import("@wizardconnect/dapp"),
|
||||
);
|
||||
|
||||
const { result } = await renderHook({
|
||||
dappName: "My Dapp",
|
||||
dappIcon: "https://example.com/icon.png",
|
||||
persistSession: false,
|
||||
});
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(DappConnectionManager).toHaveBeenCalledWith(
|
||||
"My Dapp",
|
||||
"https://example.com/icon.png",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes session option with stored paths to DappConnectionManager", async () => {
|
||||
const testPaths = [
|
||||
{ name: "receive" as const, xpub: "xpub6test1" },
|
||||
{ name: "change" as const, xpub: "xpub6test2" },
|
||||
];
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
privateKey: "d".repeat(64),
|
||||
secret: "e".repeat(16),
|
||||
walletPublicKey: "f".repeat(64),
|
||||
paths: testPaths,
|
||||
}),
|
||||
);
|
||||
|
||||
const { DappConnectionManager } = vi.mocked(
|
||||
await import("@wizardconnect/dapp"),
|
||||
);
|
||||
|
||||
await renderHook({ persistSession: true });
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
// 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"),
|
||||
);
|
||||
|
||||
const { result } = await renderHook({ persistSession: false });
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
expect(DappConnectionManager).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ session: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes relayUrls to initiateDappRelay", async () => {
|
||||
const { result } = await renderHook({
|
||||
relayUrls: ["wss://custom-relay:443"],
|
||||
persistSession: false,
|
||||
});
|
||||
|
||||
result.current.connect();
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
|
||||
expect(callArgs[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
explicitRelayUrls: ["wss://custom-relay:443"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
// 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 { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
type DappRelayResult,
|
||||
type RelayUpdatePayload,
|
||||
} from "@wizardconnect/core";
|
||||
import { DappConnectionManager, loadSession } from "@wizardconnect/dapp";
|
||||
import type {
|
||||
UseWizardConnectOptions,
|
||||
UseWizardConnectResult,
|
||||
WizardConnectState,
|
||||
} from "../types.js";
|
||||
|
||||
const DEFAULT_SESSION_KEY = "wizardconnect-session";
|
||||
|
||||
/**
|
||||
* React hook that encapsulates the WizardConnect relay lifecycle.
|
||||
*
|
||||
* Manages: relay initiation, DappConnectionManager, key exchange events,
|
||||
* session persistence, and auto-reconnect on page refresh.
|
||||
*
|
||||
* The returned `manager` can be used to build an app-specific wallet adapter.
|
||||
*/
|
||||
export function useWizardConnect(
|
||||
options: UseWizardConnectOptions = {},
|
||||
): UseWizardConnectResult {
|
||||
const {
|
||||
dappName,
|
||||
dappIcon,
|
||||
relayUrls,
|
||||
sessionKey = DEFAULT_SESSION_KEY,
|
||||
persistSession = true,
|
||||
storage,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<WizardConnectState>("idle");
|
||||
const [manager, setManager] = useState<DappConnectionManager | null>(null);
|
||||
const [uri, setUri] = useState<string | null>(null);
|
||||
const [qrUri, setQrUri] = useState<string | null>(null);
|
||||
const [walletName, setWalletName] = useState<string | null>(null);
|
||||
const [walletIcon, setWalletIcon] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const relayRef = useRef<DappRelayResult | null>(null);
|
||||
const managerRef = useRef<DappConnectionManager | null>(null);
|
||||
const autoReconnectAttempted = useRef(false);
|
||||
|
||||
const startRelay = useCallback(
|
||||
(existingCredentials?: {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
walletPublicKey: string;
|
||||
}): boolean => {
|
||||
if (state === "connecting" || state === "connected") return false;
|
||||
|
||||
setError(null);
|
||||
setState("connecting");
|
||||
|
||||
const mgr = new DappConnectionManager(dappName, dappIcon, {
|
||||
session: persistSession ? { key: sessionKey, storage } : false,
|
||||
});
|
||||
managerRef.current = mgr;
|
||||
setManager(mgr);
|
||||
|
||||
mgr.on("walletready", () => {
|
||||
setWalletName(mgr.walletName);
|
||||
setWalletIcon(mgr.walletIcon);
|
||||
setState("connected");
|
||||
});
|
||||
|
||||
mgr.on("reconnecting", () => {
|
||||
setState("reconnecting");
|
||||
});
|
||||
|
||||
mgr.on("disconnect", () => {
|
||||
setState("disconnected");
|
||||
setWalletName(null);
|
||||
setWalletIcon(null);
|
||||
mgr.clearStoredSession();
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
});
|
||||
|
||||
try {
|
||||
const relay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
mgr.updateConnection(payload.client, payload.status);
|
||||
},
|
||||
{
|
||||
existingCredentials,
|
||||
explicitRelayUrls: relayUrls,
|
||||
},
|
||||
);
|
||||
|
||||
relayRef.current = relay;
|
||||
// Only expose URI for new connections (QR pairing), not reconnects
|
||||
if (!existingCredentials) {
|
||||
setUri(relay.uri);
|
||||
setQrUri(relay.qrUri);
|
||||
}
|
||||
mgr.attachRelay(relay);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : "Failed to start relay";
|
||||
setError(message);
|
||||
setState("idle");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession, storage],
|
||||
);
|
||||
|
||||
const connect = useCallback((): boolean => {
|
||||
return startRelay();
|
||||
}, [startRelay]);
|
||||
|
||||
const disconnect = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (managerRef.current) {
|
||||
await managerRef.current.sendDisconnect().catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
managerRef.current?.clearStoredSession();
|
||||
managerRef.current = null;
|
||||
setManager(null);
|
||||
setUri(null);
|
||||
setQrUri(null);
|
||||
setWalletName(null);
|
||||
setWalletIcon(null);
|
||||
setState("idle");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-reconnect on mount if a stored session exists
|
||||
useEffect(() => {
|
||||
if (autoReconnectAttempted.current) return;
|
||||
if (!persistSession) return;
|
||||
autoReconnectAttempted.current = true;
|
||||
|
||||
// Read session before creating the manager (need credentials for startRelay)
|
||||
const stored = loadSession(sessionKey, storage);
|
||||
if (!stored || !stored.walletPublicKey) return;
|
||||
|
||||
if (stored.walletName) {
|
||||
setWalletName(stored.walletName);
|
||||
}
|
||||
|
||||
startRelay({
|
||||
privateKey: stored.privateKey,
|
||||
secret: stored.secret,
|
||||
walletPublicKey: stored.walletPublicKey,
|
||||
});
|
||||
}, [persistSession, sessionKey, storage, startRelay]);
|
||||
|
||||
// Cleanup on unmount.
|
||||
//
|
||||
// Reset `autoReconnectAttempted` here so React 18 StrictMode's dev-mode
|
||||
// mount→unmount→remount cycle doesn't leave the hook in a "attempted but
|
||||
// torn down" state. Without the reset:
|
||||
// - Mount A: auto-reconnect fires, creates relay, ref → true.
|
||||
// - StrictMode cleanup: relay destroyed.
|
||||
// - Mount B: auto-reconnect sees ref === true, skips. No new relay.
|
||||
// Result: a stored session never reconnects in dev. Resetting the ref on
|
||||
// cleanup lets Mount B re-fire the auto-reconnect path and rebuild the
|
||||
// relay. In production (no StrictMode) this only runs at real unmount, so
|
||||
// the reset is harmless there.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
managerRef.current?.destroy();
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
autoReconnectAttempted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
manager,
|
||||
uri,
|
||||
qrUri,
|
||||
walletName,
|
||||
walletIcon,
|
||||
connect,
|
||||
disconnect,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// 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
|
||||
|
||||
export { WizardConnectQRDialog } from "./components/WizardConnectQRDialog.js";
|
||||
export { AlphanumericQRCode } from "./components/AlphanumericQRCode.js";
|
||||
export { useWizardConnect } from "./hooks/useWizardConnect.js";
|
||||
export type {
|
||||
WizardConnectQRDialogProps,
|
||||
WizardConnectQRTheme,
|
||||
AlphanumericQRCodeProps,
|
||||
UseWizardConnectOptions,
|
||||
UseWizardConnectResult,
|
||||
WizardConnectState,
|
||||
} from "./types.js";
|
||||
export type { SessionStorage } from "@wizardconnect/dapp";
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
// 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 {
|
||||
DappConnectionManager,
|
||||
SessionStorage,
|
||||
} from "@wizardconnect/dapp";
|
||||
|
||||
// ---- QR Code ----
|
||||
|
||||
export interface AlphanumericQRCodeProps {
|
||||
/** The alphanumeric string to encode. */
|
||||
value: string;
|
||||
/** QR code pixel size. Default: 280 */
|
||||
size?: number;
|
||||
/** Module (dark cell) color. Default: "#1e2a4a" */
|
||||
foreground?: string;
|
||||
/** Background color. Default: "#ffffff" */
|
||||
background?: string;
|
||||
/** Quiet zone size in pixels. Default: 4 */
|
||||
quietZone?: number;
|
||||
}
|
||||
|
||||
// ---- QR Dialog ----
|
||||
|
||||
export interface WizardConnectQRTheme {
|
||||
/** Backdrop overlay color. Default: "rgba(0,0,0,0.5)" */
|
||||
backdropColor?: string;
|
||||
/** Dialog body background. Default: "#1a1f2e" */
|
||||
dialogBackground?: string;
|
||||
/** Header bar background. Default: "#1a1f2e" */
|
||||
headerBackground?: string;
|
||||
/** Title text color. Default: "#ffffff" */
|
||||
titleColor?: string;
|
||||
/** Subtitle text color. Default: "#9ca3af" */
|
||||
subtitleColor?: string;
|
||||
/** QR foreground (module) color. Default: "#1e2a4a" */
|
||||
qrForeground?: string;
|
||||
/** QR background color. Default: "#ffffff" */
|
||||
qrBackground?: string;
|
||||
/** URI display row background. Default: "rgba(31,41,55,0.6)" */
|
||||
uriRowBackground?: string;
|
||||
/** URI text color. Default: "#9ca3af" */
|
||||
uriTextColor?: string;
|
||||
/** Border color. Default: "#374151" */
|
||||
borderColor?: string;
|
||||
/** Close button color. Default: "#9ca3af" */
|
||||
closeButtonColor?: string;
|
||||
/** Copy button color. Default: "#9ca3af" */
|
||||
copyButtonColor?: string;
|
||||
/** QR code size in pixels. Default: 280 */
|
||||
qrSize?: number;
|
||||
}
|
||||
|
||||
export interface WizardConnectQRDialogProps {
|
||||
/** Whether the dialog is visible. */
|
||||
show: boolean;
|
||||
/** Called when the user clicks close or the backdrop. */
|
||||
onClose: () => void;
|
||||
/** The human-readable URI to display (lowercase wiz://...). */
|
||||
uri: string;
|
||||
/** The QR-alphanumeric-safe URI for encoding (uppercase WIZ://...). */
|
||||
qrUri: string;
|
||||
/** Called when the user clicks the copy button. Receives the uri string.
|
||||
* If not provided, uses navigator.clipboard.writeText(). */
|
||||
onCopy?: (uri: string) => void;
|
||||
/** Theme overrides. */
|
||||
theme?: WizardConnectQRTheme;
|
||||
/** Additional CSS class on the outermost container. */
|
||||
className?: string;
|
||||
/** Subtitle text. Default: "Scan with your wallet to connect" */
|
||||
subtitle?: string;
|
||||
/** Title text. Default: "WizardConnect" */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ---- Hook ----
|
||||
|
||||
export type WizardConnectState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected";
|
||||
|
||||
export interface UseWizardConnectOptions {
|
||||
/** Display name of the dapp. */
|
||||
dappName?: string;
|
||||
/** Icon URL/data-URI of the dapp. */
|
||||
dappIcon?: string;
|
||||
/** Explicit relay URLs. */
|
||||
relayUrls?: string[];
|
||||
/** LocalStorage key for session persistence. Default: "wizardconnect-session" */
|
||||
sessionKey?: string;
|
||||
/** Whether to persist session for auto-reconnect. Default: true */
|
||||
persistSession?: boolean;
|
||||
/** Custom storage backend. Defaults to localStorage. */
|
||||
storage?: SessionStorage;
|
||||
}
|
||||
|
||||
export interface UseWizardConnectResult {
|
||||
/** Current connection state. */
|
||||
state: WizardConnectState;
|
||||
/** The DappConnectionManager (null before connect()). */
|
||||
manager: DappConnectionManager | null;
|
||||
/** The connection URI (null before connect()). */
|
||||
uri: string | null;
|
||||
/** The QR-alphanumeric URI (null before connect()). */
|
||||
qrUri: string | null;
|
||||
/** Wallet name (null until walletready). */
|
||||
walletName: string | null;
|
||||
/** Wallet icon (null until walletready). */
|
||||
walletIcon: string | null;
|
||||
/** Initiate a new connection. Returns false if already connecting/connected. */
|
||||
connect: () => boolean;
|
||||
/** Disconnect and clean up. */
|
||||
disconnect: () => Promise<void>;
|
||||
/** Error message if connection failed. */
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
// 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 path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@wizardconnect/core": path.resolve(__dirname, "../core/src/index.ts"),
|
||||
"@wizardconnect/dapp": path.resolve(__dirname, "../dapp/src/index.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI for testing WizardConnect protocol",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
|
|
|||
|
|
@ -22,17 +22,13 @@ program
|
|||
.option(
|
||||
"-r, --relay <url>",
|
||||
"Nostr relay WebSocket URL",
|
||||
"wss://relay.riften.net:443",
|
||||
"wss://relay.cauldron.quest:443",
|
||||
)
|
||||
.option(
|
||||
"-k, --private-key <hex>",
|
||||
"Existing dapp private key (64 hex chars) — for reconnection testing",
|
||||
)
|
||||
.option("--secret <hex>", "Existing secret (hex) — for reconnection testing")
|
||||
.option(
|
||||
"--wallet-public-key <hex>",
|
||||
"Wallet's Nostr public key (hex) — for reconnection testing",
|
||||
)
|
||||
.option(
|
||||
"--sign",
|
||||
"Send a dummy sign request after wallet is ready (tests approval flow)",
|
||||
|
|
@ -47,7 +43,7 @@ program
|
|||
.option(
|
||||
"-r, --relay <url>",
|
||||
"Nostr relay WebSocket URL",
|
||||
"wss://relay.riften.net:443",
|
||||
"wss://relay.cauldron.quest:443",
|
||||
)
|
||||
.requiredOption("-u, --uri <uri>", "wiz:// URI from dapp")
|
||||
.option(
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ async function sendSignRequest(
|
|||
userPrompt: "Test sign request from wiz-test CLI",
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
|
|
@ -181,7 +180,6 @@ export async function runDappMode(options: {
|
|||
relay: string;
|
||||
privateKey?: string;
|
||||
secret?: string;
|
||||
walletPublicKey?: string;
|
||||
sign?: boolean;
|
||||
}): Promise<void> {
|
||||
const state = makeState();
|
||||
|
|
@ -219,12 +217,8 @@ export async function runDappMode(options: {
|
|||
{
|
||||
explicitRelayUrls: [options.relay],
|
||||
existingCredentials:
|
||||
options.privateKey && options.secret && options.walletPublicKey
|
||||
? {
|
||||
privateKey: options.privateKey,
|
||||
secret: options.secret,
|
||||
walletPublicKey: options.walletPublicKey,
|
||||
}
|
||||
options.privateKey && options.secret
|
||||
? { privateKey: options.privateKey, secret: options.secret }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
|
@ -240,11 +234,19 @@ export async function runDappMode(options: {
|
|||
encodeURIComponent(dappRelay.uri),
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\nReconnect (after key exchange):\n wiz-test dapp --private-key ${options.privateKey ?? dappRelay.credentials.privateKey} --secret ${dappRelay.credentials.secret} --wallet-public-key <WALLET_PUBKEY>`,
|
||||
),
|
||||
);
|
||||
if (options.privateKey) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\nReconnect:\n wiz-test dapp --private-key ${options.privateKey} --secret ${dappRelay.credentials.secret}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\nReconnect:\n wiz-test dapp --private-key ${dappRelay.credentials.privateKey} --secret ${dappRelay.credentials.secret}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
|
||||
const keySpinner = ora("Waiting for wallet to connect...").start();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
{
|
||||
"name": "@wizardconnect/wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"description": "Wallet-side integration helpers for WizardConnect",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitlab.com/riftenlabs/lib/wizardconnect",
|
||||
"directory": "packages/wallet"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
|
|
@ -31,6 +26,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* Chunk transport extension — end-to-end tests against a real relay.
|
||||
*
|
||||
* Exercises the NIP-44 size-limit workaround: messages that exceed the
|
||||
* 65,535-byte plaintext ceiling must be split on the sender and reassembled
|
||||
* on the receiver, symmetric in both directions.
|
||||
*
|
||||
* These tests deliberately use payloads in the same size range as real-world
|
||||
* swap transactions:
|
||||
* - ~80 KB: typical pool-aggregated swap request
|
||||
* - ~200 KB: large swap request
|
||||
* - ~100 KB signed tx hex response (policy-max)
|
||||
* - ~2 MB signed tx hex response (consensus-max)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
RelayMsgAction,
|
||||
type SignTransactionRequest,
|
||||
type SignTransactionResponse,
|
||||
} from "@wizardconnect/core";
|
||||
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
|
||||
import type { PendingSignRequest } from "../wallet-connection-manager.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
|
||||
describe("chunk extension — dapp → wallet oversized requests", () => {
|
||||
let conn: ConnectionHandles;
|
||||
const pending: PendingSignRequest[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
conn = await setupConnection(TEST_RELAY_URL);
|
||||
conn.wallet.manager.on("pendingSignRequest", (req) => pending.push(req));
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
conn?.cleanup();
|
||||
});
|
||||
|
||||
it("wallet_ready advertises the chunk transport extension", () => {
|
||||
const wr = conn.dapp.walletReadyMessages[0];
|
||||
expect(wr.extensions?.chunk).toBeDefined();
|
||||
});
|
||||
|
||||
it("reassembles an 80 KB sign_transaction_request", async () => {
|
||||
const bigHex = "ab".repeat(40_000); // 80 KB hex string
|
||||
const msg: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 100,
|
||||
transaction: {
|
||||
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||
sourceOutputs: [],
|
||||
userPrompt: bigHex,
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await conn.dapp.client()!.relay(msg);
|
||||
|
||||
await waitFor(() => pending.some((p) => p.request.sequence === 100), {
|
||||
timeoutMs: 30000,
|
||||
what: "80 KB sign request reassembled on wallet",
|
||||
});
|
||||
|
||||
const got = pending.find((p) => p.request.sequence === 100)!;
|
||||
expect(got.request.transaction.userPrompt).toBe(bigHex);
|
||||
}, 60000);
|
||||
|
||||
it("reassembles a 200 KB sign_transaction_request", async () => {
|
||||
const bigHex = "cd".repeat(100_000); // 200 KB hex
|
||||
const msg: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 101,
|
||||
transaction: {
|
||||
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||
sourceOutputs: [],
|
||||
userPrompt: bigHex,
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await conn.dapp.client()!.relay(msg);
|
||||
|
||||
await waitFor(() => pending.some((p) => p.request.sequence === 101), {
|
||||
timeoutMs: 45000,
|
||||
what: "200 KB sign request reassembled on wallet",
|
||||
});
|
||||
|
||||
const got = pending.find((p) => p.request.sequence === 101)!;
|
||||
expect(got.request.transaction.userPrompt).toBe(bigHex);
|
||||
expect(got.request.transaction.userPrompt!.length).toBe(200_000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("chunk extension — wallet → dapp oversized responses", () => {
|
||||
let conn: ConnectionHandles;
|
||||
|
||||
beforeAll(async () => {
|
||||
conn = await setupConnection(TEST_RELAY_URL);
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
conn?.cleanup();
|
||||
});
|
||||
|
||||
async function walletSend(msg: SignTransactionResponse): Promise<void> {
|
||||
// Reach through WalletConnectionManager internals to get the RelayClient
|
||||
// directly and publish. A production app would return the response
|
||||
// through its normal sign-approval flow; integration tests bypass that.
|
||||
const m = conn.wallet.manager as unknown as {
|
||||
connections: Map<
|
||||
string,
|
||||
{ client: { relay: (m: unknown) => Promise<void> } }
|
||||
>;
|
||||
};
|
||||
const connEntry = m.connections.get(conn.wallet.connectionId);
|
||||
if (!connEntry?.client) throw new Error("wallet relay client not ready");
|
||||
await connEntry.client.relay(msg);
|
||||
}
|
||||
|
||||
it("reassembles a ~100 KB signed tx hex response", async () => {
|
||||
const hex = "ef".repeat(50_000); // 100 KB hex
|
||||
const msg: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: 200,
|
||||
signedTransaction: hex,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await walletSend(msg);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
conn.dapp.messages.some(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 200,
|
||||
),
|
||||
{
|
||||
timeoutMs: 45000,
|
||||
what: "100 KB sign response reassembled on dapp",
|
||||
},
|
||||
);
|
||||
|
||||
const got = conn.dapp.messages.find(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 200,
|
||||
) as SignTransactionResponse;
|
||||
expect(got.signedTransaction).toBe(hex);
|
||||
}, 60000);
|
||||
|
||||
it("reassembles a ~2 MB signed tx hex response (consensus-max case)", async () => {
|
||||
const hex = "f0".repeat(1_000_000); // 2 MB hex
|
||||
const msg: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: 201,
|
||||
signedTransaction: hex,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await walletSend(msg);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
conn.dapp.messages.some(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 201,
|
||||
),
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
what: "2 MB sign response reassembled on dapp",
|
||||
},
|
||||
);
|
||||
|
||||
const got = conn.dapp.messages.find(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 201,
|
||||
) as SignTransactionResponse;
|
||||
expect(got.signedTransaction.length).toBe(2_000_000);
|
||||
expect(got.signedTransaction).toBe(hex);
|
||||
}, 180000);
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* Wallet-initiated disconnect must actually reach the dapp.
|
||||
*
|
||||
* disconnect.test.ts covers the other direction — the dapp sends `disconnect`
|
||||
* and the wallet reacts. Nothing covered wallet → dapp, which is how this got
|
||||
* shipped broken: doDisconnect fired the courtesy message without awaiting it and
|
||||
* then tore the relay connection down, so the publish died mid-flight and the
|
||||
* dapp went on believing the wallet was connected until its own liveness
|
||||
* timeout. Downstream wallets were carrying a patch for it.
|
||||
*
|
||||
* These tests are worth their runtime because the failure is invisible locally —
|
||||
* the wallet's own state is correct either way, and only the peer notices.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { RelayMsgAction, DisconnectReason } from "@wizardconnect/core";
|
||||
import type { DisconnectMessage, ProtocolMessage } from "@wizardconnect/core";
|
||||
|
||||
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
|
||||
|
||||
let handles: ConnectionHandles | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
handles?.cleanup();
|
||||
handles = null;
|
||||
});
|
||||
|
||||
/** Disconnect messages the dapp actually received off the relay. */
|
||||
function disconnectsSeenByDapp(h: ConnectionHandles): DisconnectMessage[] {
|
||||
return h.dapp.messages.filter(
|
||||
(msg: ProtocolMessage) => msg.action === RelayMsgAction.Disconnect,
|
||||
) as DisconnectMessage[];
|
||||
}
|
||||
|
||||
describe("wallet-initiated disconnect", () => {
|
||||
it("delivers the courtesy disconnect to the dapp", async () => {
|
||||
handles = await setupConnection();
|
||||
expect(disconnectsSeenByDapp(handles)).toHaveLength(0);
|
||||
|
||||
handles.wallet.manager.disconnect(handles.wallet.connectionId);
|
||||
|
||||
// The whole point: this arrives over a real relay, which it cannot do if the
|
||||
// connection is torn down while the publish is still in flight.
|
||||
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
|
||||
timeoutMs: 20000,
|
||||
what: "disconnect message received by the dapp",
|
||||
});
|
||||
|
||||
expect(disconnectsSeenByDapp(handles)[0].reason).toBe(
|
||||
DisconnectReason.UserDisconnect,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the connection immediately, without waiting for delivery", async () => {
|
||||
// Teardown is deferred, but the registry must not be: a caller that
|
||||
// disconnects and then inspects state should never see the dying connection.
|
||||
handles = await setupConnection();
|
||||
const { manager, connectionId } = handles.wallet;
|
||||
expect(Object.keys(manager.getConnections())).toContain(connectionId);
|
||||
|
||||
manager.disconnect(connectionId);
|
||||
|
||||
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
|
||||
});
|
||||
|
||||
it("delivers a disconnect for every connection in disconnectAll", async () => {
|
||||
handles = await setupConnection();
|
||||
|
||||
handles.wallet.manager.disconnectAll();
|
||||
|
||||
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
|
||||
timeoutMs: 20000,
|
||||
what: "disconnect message from disconnectAll",
|
||||
});
|
||||
expect(Object.keys(handles.wallet.manager.getConnections())).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the dapp reconnect on the same URI afterwards", async () => {
|
||||
// Deferring teardown must not leave the URI unusable — connect() returns an
|
||||
// existing connection for a URI, so a stale entry would be handed back.
|
||||
handles = await setupConnection();
|
||||
const { manager, connectionId } = handles.wallet;
|
||||
|
||||
manager.disconnect(connectionId);
|
||||
const reconnectedId = manager.connect(handles.dapp.uri);
|
||||
|
||||
expect(reconnectedId).not.toBe(connectionId);
|
||||
expect(Object.keys(manager.getConnections())).toContain(reconnectedId);
|
||||
|
||||
manager.disconnect(reconnectedId);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ import { WalletConnectionManager } from "@wizardconnect/wallet";
|
|||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
describe("WalletConnectionManager — disconnect", () => {
|
||||
let dappCleanup: () => void;
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ import {
|
|||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
type RelayClient,
|
||||
type RelayUpdatePayload,
|
||||
type DappReadyMessage,
|
||||
|
|
@ -112,10 +110,6 @@ export interface DappHandle {
|
|||
cleanup: () => void;
|
||||
/** All wallet_ready messages received. */
|
||||
walletReadyMessages: WalletReadyMessage[];
|
||||
/** RelayClient for direct message publishing (populated after key exchange). */
|
||||
client: () => RelayClient | null;
|
||||
/** All non-handshake messages received by the dapp. */
|
||||
messages: ProtocolMessage[];
|
||||
}
|
||||
|
||||
// ---- WalletHandle -----------------------------------------------------------
|
||||
|
|
@ -145,7 +139,8 @@ export interface ConnectionHandles {
|
|||
* 3. wallet_ready with paths
|
||||
*/
|
||||
export async function setupConnection(
|
||||
relayUrl: string = process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443",
|
||||
relayUrl: string = process.env.TEST_RELAY_URL ??
|
||||
"wss://relay.cauldron.quest:443",
|
||||
seed?: Uint8Array,
|
||||
): Promise<ConnectionHandles> {
|
||||
const adapter = makeTestAdapter(seed);
|
||||
|
|
@ -153,7 +148,6 @@ export async function setupConnection(
|
|||
// ---- Dapp side ----
|
||||
|
||||
const walletReadyMessages: WalletReadyMessage[] = [];
|
||||
const allMessages: ProtocolMessage[] = [];
|
||||
let dappClient: RelayClient | null = null;
|
||||
let keyExchanged = false;
|
||||
|
||||
|
|
@ -170,9 +164,6 @@ export async function setupConnection(
|
|||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: wd,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
// Advertise transport-level chunking so the wallet can return
|
||||
// oversized sign_transaction_response messages.
|
||||
extensions: { chunk: chunkExtensionAdvertisement() },
|
||||
};
|
||||
await dappClient!.relay(msg);
|
||||
}
|
||||
|
|
@ -192,51 +183,16 @@ export async function setupConnection(
|
|||
if (message.action === RelayMsgAction.WalletReady) {
|
||||
const msg = message as WalletReadyMessage;
|
||||
walletReadyMessages.push(msg);
|
||||
// Mirror DappConnectionManager behavior: enable chunked outbound if
|
||||
// the wallet advertises support.
|
||||
dappClient!.setPeerCapabilities({
|
||||
chunk: peerSupportsChunk(msg.extensions),
|
||||
});
|
||||
if (!msg.dapp_discovered) {
|
||||
sendDappReady(true).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
allMessages.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Prompt one more wallet_ready, now that the handler above is registered.
|
||||
// The wallet_ready that completed key exchange was consumed by
|
||||
// initiateDappRelay before this handler existed, so it is not in
|
||||
// walletReadyMessages and the caller's "wallet_ready with paths" wait
|
||||
// needs a fresh one. dapp_discovered=false is what makes the wallet
|
||||
// re-send.
|
||||
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
|
||||
await sendDappReady(false);
|
||||
});
|
||||
|
||||
// Re-announce until key exchange completes.
|
||||
//
|
||||
// The wallet sends exactly one wallet_ready per connection cycle, and it
|
||||
// fires as soon as manager.connect() resolves — which can be before this
|
||||
// dapp's relay subscription is live. If that single message is missed there
|
||||
// is nothing to retry against: keyexchangecomplete never fires, so the
|
||||
// handler above never registers and no dapp_ready is ever sent. The suite
|
||||
// then sat until the 15s "key exchange" timeout. Under singleFork the
|
||||
// previous file's teardown is still closing sockets while this runs, which
|
||||
// is exactly when the race is won by the wrong side.
|
||||
//
|
||||
// dapp_ready(wallet_discovered=false) resets walletReadySentThisCycle on the
|
||||
// wallet, so each retry earns another wallet_ready. This is the recovery path
|
||||
// the protocol's mutual-discovery design already specifies — the harness
|
||||
// simply was not using it.
|
||||
const reannounce = setInterval(() => {
|
||||
if (keyExchanged) {
|
||||
clearInterval(reannounce);
|
||||
return;
|
||||
}
|
||||
if (dappClient) sendDappReady(false).catch(() => {});
|
||||
}, 2000);
|
||||
|
||||
// ---- Wallet side ----
|
||||
|
||||
const manager = new WalletConnectionManager(adapter);
|
||||
|
|
@ -244,16 +200,7 @@ export async function setupConnection(
|
|||
|
||||
// ---- Wait for key exchange ----
|
||||
|
||||
try {
|
||||
await waitFor(() => keyExchanged, {
|
||||
timeoutMs: 15000,
|
||||
what: "key exchange",
|
||||
});
|
||||
} finally {
|
||||
// Must not outlive the wait: on timeout a leaked interval keeps publishing
|
||||
// dapp_ready into later tests and holds the fork open.
|
||||
clearInterval(reannounce);
|
||||
}
|
||||
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
|
||||
|
||||
// ---- Wait for wallet_ready with paths ----
|
||||
|
||||
|
|
@ -271,8 +218,6 @@ export async function setupConnection(
|
|||
uri: dappRelay.uri,
|
||||
cleanup: dappRelay.cleanup,
|
||||
walletReadyMessages,
|
||||
client: () => dappClient,
|
||||
messages: allMessages,
|
||||
};
|
||||
|
||||
const walletHandle: WalletHandle = { manager, connectionId, adapter };
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { DerivationPath } from "@wizardconnect/wallet";
|
|||
import type { Hdwalletv1Session } from "@wizardconnect/core";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
const PATHS = [
|
||||
{ childIndex: 0, name: "receive" },
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* Multi-relay tests — verifies that when both dapp and wallet connect to
|
||||
* both default relays, the handshake completes and messages are not duplicated.
|
||||
*
|
||||
* nostr-tools SimplePool deduplicates events by ID across relays in
|
||||
* subscribeMany, so onevent fires at most once per event. This test exercises
|
||||
* that path over the wire with real relay connections.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
DEFAULT_RELAY_URLS,
|
||||
type RelayUpdatePayload,
|
||||
type RelayClient,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type ProtocolMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletConnectionManager } from "@wizardconnect/wallet";
|
||||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
|
||||
const RELAY_URLS = [...DEFAULT_RELAY_URLS];
|
||||
|
||||
describe("Multi-relay redundancy", () => {
|
||||
let dappCleanup: (() => void) | null = null;
|
||||
let walletManager: WalletConnectionManager | null = null;
|
||||
|
||||
afterAll(() => {
|
||||
dappCleanup?.();
|
||||
walletManager?.disconnectAll();
|
||||
});
|
||||
|
||||
it("handshake succeeds with both default relays and wallet_ready arrives once", async () => {
|
||||
const adapter = makeTestAdapter();
|
||||
|
||||
// ---- Dapp side: use both relays ----
|
||||
|
||||
const walletReadyMessages: WalletReadyMessage[] = [];
|
||||
let dappClient: RelayClient | null = null;
|
||||
let keyExchanged = false;
|
||||
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
if (payload.client && !dappClient) dappClient = payload.client;
|
||||
},
|
||||
{ explicitRelayUrls: RELAY_URLS },
|
||||
);
|
||||
dappCleanup = dappRelay.cleanup;
|
||||
|
||||
async function sendDappReady(wd: boolean): Promise<void> {
|
||||
const msg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: wd,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(msg);
|
||||
}
|
||||
|
||||
dappRelay.events.on("keyexchangecomplete", async () => {
|
||||
keyExchanged = true;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (!dappClient!.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
dappClient!.on("message", (message: ProtocolMessage) => {
|
||||
if (message.action === RelayMsgAction.WalletReady) {
|
||||
const msg = message as WalletReadyMessage;
|
||||
walletReadyMessages.push(msg);
|
||||
if (!msg.dapp_discovered) {
|
||||
sendDappReady(true).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await sendDappReady(false);
|
||||
});
|
||||
|
||||
// ---- Wallet side: connect via URI (auto-adds second default relay) ----
|
||||
|
||||
walletManager = new WalletConnectionManager(adapter);
|
||||
walletManager.connect(dappRelay.uri);
|
||||
|
||||
// ---- Wait for key exchange ----
|
||||
|
||||
await waitFor(() => keyExchanged, {
|
||||
timeoutMs: 20000,
|
||||
what: "key exchange",
|
||||
});
|
||||
|
||||
// ---- Wait for wallet_ready with paths ----
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
walletReadyMessages.length > 0 &&
|
||||
(walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length >
|
||||
0,
|
||||
{ timeoutMs: 20000, what: "wallet_ready with paths" },
|
||||
);
|
||||
|
||||
// ---- Verify single delivery ----
|
||||
|
||||
// Give extra time for any duplicate to arrive
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
// wallet_ready should arrive exactly once (not duplicated across relays)
|
||||
expect(walletReadyMessages.length).toBe(1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
|
@ -24,7 +24,7 @@ import { makeTestAdapter, waitFor } from "./helpers.js";
|
|||
import { generateRandomBytes } from "@bitauth/libauth";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
describe("WalletConnectionManager — reconnection", () => {
|
||||
// Shared dapp that stays alive across both wallet connections
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { WalletConnectionManager } from "@wizardconnect/wallet";
|
|||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
describe("WalletConnectionManager — sign_cancel", () => {
|
||||
let dappCleanup: () => void;
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
// 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
|
||||
|
||||
/**
|
||||
* sign_transaction_request tests — verifies that inputPaths sent by the dapp
|
||||
* arrive intact on the wallet side via the pendingSignRequest event.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
type RelayUpdatePayload,
|
||||
type RelayClient,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type ProtocolMessage,
|
||||
type SignTransactionRequest,
|
||||
} from "@wizardconnect/core";
|
||||
import {
|
||||
WalletConnectionManager,
|
||||
type PendingSignRequest,
|
||||
} from "@wizardconnect/wallet";
|
||||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
|
||||
describe("WalletConnectionManager — sign_transaction_request with inputPaths", () => {
|
||||
let dappCleanup: () => void;
|
||||
let dappClient: RelayClient | null = null;
|
||||
|
||||
let manager: WalletConnectionManager;
|
||||
let connectionId: string;
|
||||
|
||||
let walletReadyReceived = false;
|
||||
|
||||
const pendingRequests: PendingSignRequest[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// ---- Dapp setup ----
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
if (payload.client && !dappClient) dappClient = payload.client;
|
||||
},
|
||||
{ explicitRelayUrls: [TEST_RELAY_URL] },
|
||||
);
|
||||
|
||||
dappCleanup = dappRelay.cleanup;
|
||||
|
||||
dappRelay.events.on("keyexchangecomplete", async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (!dappClient!.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
dappClient!.on("message", (msg: ProtocolMessage) => {
|
||||
if (msg.action === RelayMsgAction.WalletReady) {
|
||||
walletReadyReceived = true;
|
||||
const wr = msg as WalletReadyMessage;
|
||||
if (!wr.dapp_discovered) {
|
||||
const reply: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: true,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
dappClient!.relay(reply).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const initMsg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: false,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(initMsg);
|
||||
});
|
||||
|
||||
// ---- Wallet side ----
|
||||
const adapter = makeTestAdapter();
|
||||
manager = new WalletConnectionManager(adapter);
|
||||
|
||||
manager.on("pendingSignRequest", (req: PendingSignRequest) => {
|
||||
pendingRequests.push(req);
|
||||
});
|
||||
|
||||
connectionId = manager.connect(dappRelay.uri);
|
||||
|
||||
await waitFor(() => walletReadyReceived, {
|
||||
timeoutMs: 15000,
|
||||
what: "wallet_ready received on dapp side",
|
||||
});
|
||||
|
||||
// Give time for reactive dapp_ready to be processed
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
dappCleanup?.();
|
||||
manager?.disconnectAll();
|
||||
});
|
||||
|
||||
it("wallet receives inputPaths from dapp sign request", async () => {
|
||||
const signMsg: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 1,
|
||||
transaction: {
|
||||
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||
sourceOutputs: [],
|
||||
userPrompt: "test",
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [
|
||||
[0, "receive", 0],
|
||||
[1, "defi", 5],
|
||||
[2, "change", 2],
|
||||
],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(signMsg);
|
||||
|
||||
await waitFor(() => pendingRequests.length > 0, {
|
||||
timeoutMs: 5000,
|
||||
what: "pendingSignRequest event on wallet",
|
||||
});
|
||||
|
||||
expect(pendingRequests[0].connectionId).toBe(connectionId);
|
||||
expect(pendingRequests[0].request.inputPaths).toEqual([
|
||||
[0, "receive", 0],
|
||||
[1, "defi", 5],
|
||||
[2, "change", 2],
|
||||
]);
|
||||
}, 15000);
|
||||
|
||||
it("wallet receives empty inputPaths for zero-input transaction", async () => {
|
||||
const signMsg: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 2,
|
||||
transaction: {
|
||||
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||
sourceOutputs: [],
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(signMsg);
|
||||
|
||||
await waitFor(() => pendingRequests.length >= 2, {
|
||||
timeoutMs: 5000,
|
||||
what: "second pendingSignRequest event on wallet",
|
||||
});
|
||||
|
||||
expect(pendingRequests[1].request.inputPaths).toEqual([]);
|
||||
}, 15000);
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
import { SignTransactionRequest, PathXpub } from "@wizardconnect/core";
|
||||
import { SignTransactionRequest } from "@wizardconnect/core";
|
||||
|
||||
export interface SignTransactionResult {
|
||||
signedTransaction: string;
|
||||
|
|
@ -45,18 +45,4 @@ export interface WalletAdapter {
|
|||
signTransaction(
|
||||
request: SignTransactionRequest,
|
||||
): Promise<SignTransactionResult>;
|
||||
|
||||
/**
|
||||
* Optional additional paths beyond receive/change/defi to include in the
|
||||
* hdwalletv1 session handshake (e.g. stealth_scan, stealth_spend, rpa).
|
||||
* See docs/extensions.md for conventions.
|
||||
*/
|
||||
getAdditionalPaths?(): PathXpub[];
|
||||
|
||||
/**
|
||||
* Optional extension data to include in the hdwalletv1 session handshake.
|
||||
* Each key is an extension name; its presence indicates wallet support.
|
||||
* See docs/extensions.md for conventions.
|
||||
*/
|
||||
getExtensions?(): Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import {
|
||||
RelayMsgAction,
|
||||
RelayStatus,
|
||||
type SignTransactionRequest,
|
||||
type RelayStatusCallback,
|
||||
type RelayUpdatePayload,
|
||||
} from "@wizardconnect/core";
|
||||
import type { WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
|
||||
// --- Mock initiateWalletRelay so we can drive the connection from tests ------
|
||||
|
||||
let capturedCallback: RelayStatusCallback | null = null;
|
||||
|
||||
/** Minimal RelayClient mock — EventEmitter + relay spy. */
|
||||
function makeMockClient() {
|
||||
const emitter = new EventEmitter();
|
||||
return {
|
||||
on: emitter.on.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
relay: vi.fn(async () => {}),
|
||||
isKeyExchangeComplete: () => true,
|
||||
setPairedPublicKey: vi.fn(),
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
type MockClient = ReturnType<typeof makeMockClient>;
|
||||
|
||||
let mockClient: MockClient;
|
||||
|
||||
vi.mock("@wizardconnect/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@wizardconnect/core")>(
|
||||
"@wizardconnect/core",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
initiateWalletRelay: (cb: RelayStatusCallback) => {
|
||||
capturedCallback = cb;
|
||||
return {
|
||||
client: mockClient,
|
||||
dappPublicKey: new Uint8Array(32),
|
||||
walletPublicKey: new Uint8Array(33).fill(0x02),
|
||||
secret: "aa".repeat(16),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock so the module picks up the mock
|
||||
const { WalletConnectionManager } =
|
||||
await import("./wallet-connection-manager.js");
|
||||
|
||||
// --- Helpers -----------------------------------------------------------------
|
||||
|
||||
function makeAdapter(): WalletAdapter {
|
||||
return {
|
||||
walletName: "Test Wallet",
|
||||
walletIcon: "",
|
||||
getRelayPrivateKey: () => new Uint8Array(32).fill(0x01),
|
||||
getPublicKey: () => new Uint8Array(33).fill(0x02),
|
||||
getXpub: (_path: DerivationPath) =>
|
||||
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
|
||||
signTransaction: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSignRequest(sequence: number): SignTransactionRequest {
|
||||
return {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
inputPaths: [],
|
||||
transaction: "deadbeef",
|
||||
};
|
||||
}
|
||||
|
||||
/** Simulate connection: push status callback + emit messages via mock client. */
|
||||
function simulateConnection(): MockClient {
|
||||
if (!capturedCallback) throw new Error("connect() not called yet");
|
||||
const payload: RelayUpdatePayload = {
|
||||
client: mockClient as any,
|
||||
status: RelayStatus.connected(),
|
||||
};
|
||||
capturedCallback(payload);
|
||||
return mockClient;
|
||||
}
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
||||
describe("WalletConnectionManager — sign request dedup", () => {
|
||||
beforeEach(() => {
|
||||
capturedCallback = null;
|
||||
mockClient = makeMockClient();
|
||||
(WalletConnectionManager as any).uriSignSequences.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not emit duplicate pendingSignRequest for the same sequence", async () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const request = makeSignRequest(42);
|
||||
// First delivery — wallet sees the sign request
|
||||
client.emit("message", request);
|
||||
// Second delivery — dapp re-sent after reconnect
|
||||
client.emit("message", request);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(emitted).toEqual([42]);
|
||||
});
|
||||
|
||||
it("keeps dedup guard active after sign response to block relay re-delivery", async () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
const connId = mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const request = makeSignRequest(42);
|
||||
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
await mgr.sendSignResponse(connId, 42, "signed_hex");
|
||||
|
||||
// Nostr relay re-delivers the stored sign request after reconnect —
|
||||
// must NOT prompt the user a second time
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
});
|
||||
|
||||
it("keeps dedup guard active after sign cancel to block relay re-delivery", async () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const request = makeSignRequest(42);
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
// Dapp cancels
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 42,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Relay re-delivers the old sign request — must still be filtered
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalletConnectionManager — ping → pong", () => {
|
||||
beforeEach(() => {
|
||||
capturedCallback = null;
|
||||
mockClient = makeMockClient();
|
||||
(WalletConnectionManager as any).uriSignSequences.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("responds to a Ping message with a Pong", () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
||||
client.relay.mockClear();
|
||||
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.Ping,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
const pongCalls = client.relay.mock.calls.filter(
|
||||
([msg]) => msg.action === RelayMsgAction.Pong,
|
||||
);
|
||||
expect(pongCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalletConnectionManager — per-connection signSequence cleanup", () => {
|
||||
beforeEach(() => {
|
||||
capturedCallback = null;
|
||||
mockClient = makeMockClient();
|
||||
(WalletConnectionManager as any).uriSignSequences.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("blocks relay-replayed COMPLETED sequence on reconnect to same URI", async () => {
|
||||
// A sequence that was fully signed must be blocked on relay replay even
|
||||
// after an explicit disconnect()+connect() (e.g. Paytaca 65s watchdog).
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const connId1 = mgr.connect("wiz://test");
|
||||
const firstClient = simulateConnection();
|
||||
firstClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
// Complete the request — this is what persists seq 42 to the URI cache
|
||||
await mgr.sendSignResponse(connId1, 42, "signed_hex");
|
||||
|
||||
mgr.disconnect(connId1);
|
||||
|
||||
// Relay replays seq=42 on the new connection — must be blocked
|
||||
mockClient = makeMockClient();
|
||||
mgr.connect("wiz://test");
|
||||
const secondClient = simulateConnection();
|
||||
secondClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42]); // still just [42]
|
||||
|
||||
// A genuinely new sequence from the dapp is accepted
|
||||
secondClient.emit("message", makeSignRequest(44));
|
||||
expect(emitted).toEqual([42, 44]);
|
||||
});
|
||||
|
||||
it("allows dapp to resend a PENDING sequence after watchdog disconnect", () => {
|
||||
// If the Paytaca watchdog fires while the user is mid-signing, the dapp
|
||||
// must be able to resend the request on the new connection. Pending sequences
|
||||
// must NOT be stored in the URI cache until a response is sent.
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const connId1 = mgr.connect("wiz://test");
|
||||
const firstClient = simulateConnection();
|
||||
firstClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
// Watchdog fires BEFORE user signs — seq 42 is still pending, no response sent
|
||||
mgr.disconnect(connId1);
|
||||
|
||||
// Dapp reconnects and resends the same pending request — must be accepted
|
||||
mockClient = makeMockClient();
|
||||
mgr.connect("wiz://test");
|
||||
const secondClient = simulateConnection();
|
||||
secondClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42, 42]); // resend accepted, user prompted again
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,6 @@ import { EventEmitter } from "eventemitter3";
|
|||
import {
|
||||
RelayClient,
|
||||
RelayStatus,
|
||||
ProtocolMessage,
|
||||
RelayUpdatePayload,
|
||||
RelayStatusCallback,
|
||||
initiateWalletRelay,
|
||||
|
|
@ -18,14 +17,11 @@ import {
|
|||
WalletReadyMessage,
|
||||
DisconnectMessage,
|
||||
DisconnectReason,
|
||||
PingMessage,
|
||||
PongMessage,
|
||||
PathXpub,
|
||||
Hdwalletv1Session,
|
||||
ProtocolMessage,
|
||||
PROTOCOL_NAME,
|
||||
binToHex,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
|
|
@ -45,15 +41,6 @@ export interface PendingSignRequest {
|
|||
request: SignTransactionRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long doDisconnect waits for the courtesy `disconnect` message to be
|
||||
* published before tearing the relay connection down anyway.
|
||||
*
|
||||
* Generous enough for a slow relay, short enough that an unreachable one cannot
|
||||
* hold the socket open indefinitely.
|
||||
*/
|
||||
const DISCONNECT_PUBLISH_TIMEOUT_MS = 5000;
|
||||
|
||||
interface ActiveConnection {
|
||||
id: string;
|
||||
uri: string;
|
||||
|
|
@ -68,8 +55,6 @@ interface ActiveConnection {
|
|||
/// Prevents duplicate wallet_ready messages within a single connection cycle.
|
||||
/// Reset to false on each new connect/reconnect; set to true after sending.
|
||||
walletReadySentThisCycle: boolean;
|
||||
/// Sign request sequences received on this connection, for cleanup on disconnect.
|
||||
signSequences: Set<number>;
|
||||
notificationQueue: ProtocolMessage[];
|
||||
notificationProcessor: ReturnType<typeof setInterval> | null;
|
||||
/// Key exchange data embedded in wallet_ready
|
||||
|
|
@ -91,8 +76,6 @@ export type WalletConnectionManagerEvents = {
|
|||
sequence: number,
|
||||
reason: string | undefined,
|
||||
];
|
||||
/** Fired for protocol messages not handled by the core protocol (extension actions). */
|
||||
message: [connectionId: string, message: ProtocolMessage];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -101,12 +84,6 @@ export type WalletConnectionManagerEvents = {
|
|||
*/
|
||||
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
|
||||
private connections: Map<string, ActiveConnection> = new Map();
|
||||
private activeSignSequences = new Set<number>();
|
||||
// Persists sign request sequences seen per URI across doDisconnect()+connect()
|
||||
// within the same JS session. The relay replays stored sign requests after
|
||||
// explicit reconnect; without this, clearing activeSignSequences in doDisconnect
|
||||
// would let replayed sequences bypass the dedup guard.
|
||||
private static readonly uriSignSequences = new Map<string, Set<number>>();
|
||||
private adapter: WalletAdapter;
|
||||
|
||||
constructor(adapter: WalletAdapter) {
|
||||
|
|
@ -140,7 +117,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
connectedAt: Date.now(),
|
||||
dappDiscovered: false,
|
||||
walletReadySentThisCycle: false,
|
||||
signSequences: new Set(),
|
||||
notificationQueue: [],
|
||||
notificationProcessor: null,
|
||||
walletPublicKeyHex: "",
|
||||
|
|
@ -149,16 +125,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
|
||||
this.connections.set(id, conn);
|
||||
|
||||
// Restore any sign sequences seen on this URI during this JS session so
|
||||
// relay-replayed requests are blocked even after a full disconnect+reconnect.
|
||||
const savedSeqs = WalletConnectionManager.uriSignSequences.get(uri);
|
||||
if (savedSeqs) {
|
||||
for (const seq of savedSeqs) {
|
||||
this.activeSignSequences.add(seq);
|
||||
conn.signSequences.add(seq);
|
||||
}
|
||||
}
|
||||
|
||||
const statusCallback: RelayStatusCallback = (
|
||||
payload: RelayUpdatePayload,
|
||||
) => {
|
||||
|
|
@ -226,58 +192,19 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
const conn = this.connections.get(connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
// Drop the connection from the registry synchronously, before any awaiting.
|
||||
// getConnections() must reflect the disconnect immediately, and connect()
|
||||
// returns an existing connection for a URI — so leaving this one in the map
|
||||
// while its teardown is pending would hand a caller a dying connection.
|
||||
for (const seq of conn.signSequences) {
|
||||
this.activeSignSequences.delete(seq);
|
||||
if (sendMessage && conn.client) {
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
conn.client.relay(disconnectMsg).catch(() => {});
|
||||
}
|
||||
|
||||
clearInterval(conn.notificationProcessor ?? undefined);
|
||||
conn.notificationProcessor = null;
|
||||
conn.cleanup();
|
||||
this.connections.delete(connectionId);
|
||||
this.emit("connectionsChanged");
|
||||
|
||||
if (!sendMessage || !conn.client) {
|
||||
conn.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Tear down only once the courtesy message has actually gone out.
|
||||
//
|
||||
// relay() resolves after `Promise.allSettled(pool.publish(...))` — a real
|
||||
// round trip to every configured relay. Calling conn.cleanup() straight
|
||||
// after firing it closed the pool underneath the in-flight publish, so the
|
||||
// disconnect usually never reached the relay and the dapp went on believing
|
||||
// the wallet was connected until its own liveness timeout fired. Downstream
|
||||
// wallets were patching this out of the published package.
|
||||
//
|
||||
// Bounded, because "the publish never settles" is exactly the case where a
|
||||
// relay is unreachable, and a socket that is never closed is worse than a
|
||||
// courtesy message that is never delivered.
|
||||
let torndown = false;
|
||||
const teardown = () => {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
conn.cleanup();
|
||||
};
|
||||
|
||||
const timer = setTimeout(teardown, DISCONNECT_PUBLISH_TIMEOUT_MS);
|
||||
conn.client
|
||||
.relay(disconnectMsg)
|
||||
.catch(() => {
|
||||
// Nothing to do: we are disconnecting either way.
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
teardown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -312,18 +239,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
throw new Error(`Connection ${connectionId} not found or not connected`);
|
||||
}
|
||||
|
||||
// Intentionally keep sequence in activeSignSequences and conn.signSequences.
|
||||
// Nostr relays replay stored events on reconnect; removing the guard here
|
||||
// would let a re-delivered sign_transaction_request pass dedup and prompt
|
||||
// the user a second time for an already-completed request.
|
||||
// doDisconnect() is the sole cleanup point for these sets.
|
||||
//
|
||||
// Now that the request is complete, persist to the URI cache so a fresh
|
||||
// connection created after a watchdog disconnect also blocks relay replays.
|
||||
// Pending sequences are NOT stored until completion so the dapp can resend
|
||||
// them if the watchdog fires while the user is still signing.
|
||||
this.persistCompletedSequence(conn, sequence);
|
||||
|
||||
const response: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence,
|
||||
|
|
@ -347,10 +262,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
return; // Already disconnected, nothing to do
|
||||
}
|
||||
|
||||
// Same reasoning as sendSignResponse: keep in dedup guard until doDisconnect.
|
||||
// Persist completed sequence to URI cache so relay replays are blocked on reconnect.
|
||||
this.persistCompletedSequence(conn, sequence);
|
||||
|
||||
const response: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence,
|
||||
|
|
@ -362,27 +273,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
await conn.client.relay(response);
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/**
|
||||
* Mark a sign sequence as completed in the URI-scoped cache.
|
||||
* Called after a response is sent or the dapp cancels the request so that
|
||||
* relay replays of the original sign_transaction_request are blocked on the
|
||||
* next reconnect. Pending sequences are intentionally NOT stored here — the
|
||||
* dapp must be able to resend them if a keepalive reconnect fires mid-signing.
|
||||
*/
|
||||
private persistCompletedSequence(
|
||||
conn: ActiveConnection,
|
||||
sequence: number,
|
||||
): void {
|
||||
let seqSet = WalletConnectionManager.uriSignSequences.get(conn.uri);
|
||||
if (!seqSet) {
|
||||
seqSet = new Set();
|
||||
WalletConnectionManager.uriSignSequences.set(conn.uri, seqSet);
|
||||
}
|
||||
seqSet.add(sequence);
|
||||
}
|
||||
|
||||
// --- Private connection lifecycle ---
|
||||
|
||||
/** Immediately attempt to flush the notification queue (fire-and-forget). */
|
||||
|
|
@ -393,11 +283,8 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
}
|
||||
|
||||
private onConnected(conn: ActiveConnection): void {
|
||||
// New connection cycle: reset dedup flags so wallet_ready is sent fresh
|
||||
// and dapp_discovered is false — the dapp must re-send dapp_ready to
|
||||
// re-establish the session (matches "Wallet reconnects" scenario in protocol.md).
|
||||
// New connection cycle: reset dedup flag so wallet_ready is sent fresh
|
||||
conn.walletReadySentThisCycle = false;
|
||||
conn.dappDiscovered = false;
|
||||
|
||||
// Start notification processor as fallback for retries after send errors
|
||||
if (conn.notificationProcessor) {
|
||||
|
|
@ -411,12 +298,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
|
||||
// Wait for key exchange, then send wallet_ready
|
||||
(async () => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (conn.client && !conn.client.isKeyExchangeComplete()) {
|
||||
if (Date.now() >= deadline) {
|
||||
console.error("[wizardconnect/wallet] Key exchange timed out");
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (conn.client) {
|
||||
|
|
@ -452,11 +334,11 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
case RelayMsgAction.SignCancel:
|
||||
this.handleSignCancel(conn, message as SignCancelMessage);
|
||||
break;
|
||||
case RelayMsgAction.Ping:
|
||||
this.handlePing(conn, message as PingMessage);
|
||||
break;
|
||||
default:
|
||||
this.emit("message", conn.id, message);
|
||||
console.warn(
|
||||
"[wizardconnect/wallet] Unknown message action:",
|
||||
message.action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,22 +354,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
conn: ActiveConnection,
|
||||
msg: SignCancelMessage,
|
||||
): void {
|
||||
// Keep in dedup guard: the relay may still re-deliver the original
|
||||
// sign_transaction_request after the cancel. doDisconnect() cleans up.
|
||||
// Persist to URI cache so the cancelled sequence is blocked on reconnect too.
|
||||
this.persistCompletedSequence(conn, msg.sequence);
|
||||
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
|
||||
}
|
||||
|
||||
private handlePing(conn: ActiveConnection, _msg: PingMessage): void {
|
||||
if (!conn.client) return;
|
||||
const pong: PongMessage = {
|
||||
action: RelayMsgAction.Pong,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
conn.client.relay(pong).catch(() => {});
|
||||
}
|
||||
|
||||
private async handleDappReady(
|
||||
conn: ActiveConnection,
|
||||
msg: DappReadyMessage,
|
||||
|
|
@ -508,15 +377,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
this.emit("connectionStatusChanged", conn.id, conn.status);
|
||||
}
|
||||
|
||||
// Transport-level capability: if the dapp advertises chunking, enable
|
||||
// chunked responses. Re-applied on every dapp_ready (cheap and idempotent),
|
||||
// so reconnects pick up capability changes.
|
||||
if (conn.client) {
|
||||
conn.client.setPeerCapabilities({
|
||||
chunk: peerSupportsChunk(msg.extensions),
|
||||
});
|
||||
}
|
||||
|
||||
if (!msg.wallet_discovered) {
|
||||
// Dapp hasn't seen us yet (or has reset, e.g. browser refresh) — force
|
||||
// re-introduction even if we already sent wallet_ready this cycle.
|
||||
|
|
@ -536,13 +396,10 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
{ name: "receive", xpub: this.adapter.getXpub(DerivationPath.Receive) },
|
||||
{ name: "change", xpub: this.adapter.getXpub(DerivationPath.Change) },
|
||||
{ name: "defi", xpub: this.adapter.getXpub(DerivationPath.Cauldron) },
|
||||
...(this.adapter.getAdditionalPaths?.() ?? []),
|
||||
];
|
||||
|
||||
const adapterExtensions = this.adapter.getExtensions?.();
|
||||
const hdwv1Session: Hdwalletv1Session = {
|
||||
paths,
|
||||
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
|
||||
};
|
||||
|
||||
const msg: WalletReadyMessage = {
|
||||
|
|
@ -557,9 +414,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
},
|
||||
public_key: conn.walletPublicKeyHex,
|
||||
secret: conn.keyExchangeSecret,
|
||||
// Transport-level: advertise chunking so the dapp can send large
|
||||
// SignTransactionRequests that exceed NIP-44's plaintext ceiling.
|
||||
extensions: { chunk: chunkExtensionAdvertisement() },
|
||||
};
|
||||
|
||||
conn.notificationQueue.push(msg);
|
||||
|
|
@ -570,15 +424,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
conn: ActiveConnection,
|
||||
msg: SignTransactionRequest,
|
||||
): void {
|
||||
// Deduplicate: the dapp re-sends pending requests after reconnect, so the
|
||||
// wallet may receive the same sequence twice while it's still awaiting
|
||||
// user approval.
|
||||
if (this.activeSignSequences.has(msg.sequence)) {
|
||||
return;
|
||||
}
|
||||
this.activeSignSequences.add(msg.sequence);
|
||||
conn.signSequences.add(msg.sequence);
|
||||
|
||||
// Emit to host app for queuing/approval
|
||||
const pendingRequest: PendingSignRequest = {
|
||||
connectionId: conn.id,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ export default defineConfig({
|
|||
include: ["src/integration/**/*.test.ts"],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
// These tests talk to live relays. A dropped connection or a slow publish
|
||||
// is an environment failure, not a regression, and without a retry a single
|
||||
// one reds the whole pipeline.
|
||||
retry: 2,
|
||||
// Run integration tests serially to avoid relay contention
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue