The sign_message extension leaves replay to the dapp, because a signature proves key control over an exact string and carries no freshness or audience. The previous commit said so in three places in the docs. That is exactly the failure this repository should not ship: a login built on a bare signMessage call works in every manual test and is a password that never expires, so documenting the requirement mostly relocates the blame. So the two failures that matter are structural here rather than advisory: verifyLoginChallenge cannot be called without `domain` and `consumeNonce`. There is no overload that omits them. Verifying a login without single-use enforcement and audience binding is not something this API can express — if you want plain signature verification, verifyMessageSignatureForAddress is right there and is honestly named. createLoginChallenge refuses a nonce under MIN_NONCE_LENGTH and refuses a line break in any field, so neither a guessable nonce nor an injected `Nonce:` line can reach a signed message. Check order is deliberate: parse, domain, expiry, signature, THEN consume the nonce. Consuming earlier would let anyone who sniffs a nonce burn it with a garbage signature before the real user finishes signing; there is a test asserting the nonce survives a bad signature and the genuine login still completes. consumeNonce is a caller-supplied callback rather than a store this module owns, because single-use enforcement is a property of the caller's database — two replays arriving together both reach that point and only one may be told true. The docstring says it must be atomic. createInMemoryNonceStore exists for development and says plainly that it is per-process, so two servers behind a load balancer would each honour the same signature once. parseLoginChallenge is strict: unknown fields, duplicate fields, out-of-order fields and stray lines are rejected rather than skipped, so exactly one byte sequence parses to a given challenge. A lenient parser is where field injection lives. Address is optional in the message because under wallet_choice the dapp does not yet know which key will answer. When present the proof is self-describing — a third party reading the message alone sees which address was claimed — and verification then requires the recovered address to match it. NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum so it reads familiarly, but it does not claim EIP-4361 or CAIP-122 compatibility: there is no agreed SIWX profile for Bitcoin Cash to conform to. If one lands it belongs beside this as a second format, not as a silent change to this one. Also adds addressesEqual() to message-signing, which compares decoded public key hashes so prefixed CashAddr, bare CashAddr, the token-aware form and legacy base58 all compare equal for the same key. 27 tests, mostly about what must be refused: the replay, the wrong site, the stale and future-dated challenge, four field-injection attempts, the nonce-burning attack, the wrong key, and a cross-encoding address match. test-cli now builds its challenge with these helpers rather than hand-rolled text, since that is what integrators copy, and verifies the response twice — once as a third party would and once as the server would, printing proof that replaying the identical signature is rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
566 lines
20 KiB
Markdown
566 lines
20 KiB
Markdown
# Dapp integration
|
||
|
||
Dapp integration uses `@wizardconnect/dapp` (for session management and pubkey state) together
|
||
with `@wizardconnect/core` (for the relay connection and URI generation).
|
||
|
||
## DappConnectionManager
|
||
|
||
Manages a single dapp–wallet session. Handles the handshake, xpub storage, and sign request
|
||
round-trips. Most dapps only ever have one active session at a time.
|
||
|
||
```typescript
|
||
class DappConnectionManager extends EventEmitter {
|
||
readonly pubkeyState: DappPubkeyStateManager;
|
||
|
||
walletName: string | null;
|
||
walletIcon: string | null;
|
||
/** 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;
|
||
})
|
||
|
||
/** Call from the RelayStatusCallback each time the relay status changes. */
|
||
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. Works for both
|
||
* sign_transaction_request and sign_message_request. */
|
||
sendSignCancel(sequence: number, reason?: string): Promise<void>
|
||
|
||
/** Ask the wallet to sign a plain message, proving key control. Resolves only
|
||
* after the result is verified. See "signMessage" below. */
|
||
signMessage(
|
||
request: { message: string; path?: PathName; addressIndex?: number;
|
||
userPrompt?: string; scheme?: MessageSignatureScheme },
|
||
options?: { signal?: AbortSignal },
|
||
): Promise<VerifiedMessageSignature>
|
||
|
||
/** Whether the wallet advertised sign_message for this mode and scheme. */
|
||
walletSupportsSignMessage(mode?: MessageSigningMode, scheme?: MessageSignatureScheme): boolean
|
||
|
||
/** Low-level: send a sign_message request and get the raw, UNVERIFIED response. */
|
||
sendSignMessageRequest(request: SignMessageRequest): Promise<SignMessageSuccess>
|
||
|
||
/** Get the next sequence number (for manual request construction). */
|
||
nextSequence(): number
|
||
|
||
/** Send a UserDisconnect courtesy message to the wallet. */
|
||
sendDisconnect(message?: string): Promise<void>
|
||
|
||
/** Get raw PathXpub[] received in wallet_ready (for caching). */
|
||
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)
|
||
on("messagereceived", (msg: ProtocolMessage) => void)
|
||
on("disconnect", (reason: DisconnectReason, message: string | undefined) => void)
|
||
}
|
||
```
|
||
|
||
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
|
||
are also available directly on the manager:
|
||
|
||
```typescript
|
||
// Get a pubkey (derives on demand from xpub if not cached)
|
||
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined
|
||
|
||
// Get all cached pubkeys for a path
|
||
getPubkeys(childIndex: number): Map<bigint, Uint8Array>
|
||
|
||
// Get/set the current address index for a path
|
||
getAddressIndex(childIndex: number): bigint
|
||
setAddressIndex(childIndex: number, index: bigint): void
|
||
|
||
// Get a smart "next index to use" (see pubkey-derivation.md)
|
||
getIndexToUse(childIndex: number, options?: { index?: bigint; reuseLast?: boolean }): bigint
|
||
|
||
// Get the min/max indices seen for a path
|
||
getIndexRange(childIndex: number): { min?: bigint; max?: bigint }
|
||
|
||
// Remove a used change address from the gap-fill queue
|
||
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
|
||
|
||
### Initial connect
|
||
|
||
```typescript
|
||
import { initiateDappRelay } from "@wizardconnect/core";
|
||
import { DappConnectionManager } from "@wizardconnect/dapp";
|
||
|
||
const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.png");
|
||
|
||
const relay = initiateDappRelay(
|
||
(payload) => {
|
||
dappMgr.updateConnection(payload.client, payload.status);
|
||
},
|
||
);
|
||
|
||
// 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);
|
||
```
|
||
|
||
### After wallet connects
|
||
|
||
```typescript
|
||
dappMgr.on("walletready", (msg) => {
|
||
console.log("Wallet:", msg.wallet_name);
|
||
// pubkeyState is now populated with xpub nodes.
|
||
// You can start deriving addresses.
|
||
});
|
||
```
|
||
|
||
### Deriving addresses
|
||
|
||
```typescript
|
||
// Get the first receive address pubkey:
|
||
const RECEIVE = 0;
|
||
const pubkey = dappMgr.getPubkey(RECEIVE, 0n); // derives from xpub if needed
|
||
```
|
||
|
||
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]],
|
||
};
|
||
|
||
const response = await dappMgr.sendSignRequest(request);
|
||
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");
|
||
```
|
||
|
||
### Disconnecting
|
||
|
||
```typescript
|
||
// Dapp-initiated: send courtesy message, clear session, then tear down the relay
|
||
dappMgr.clearStoredSession();
|
||
await dappMgr.sendDisconnect("user closed the tab");
|
||
relay.cleanup();
|
||
|
||
// Listen for wallet-initiated disconnect or protocol mismatch:
|
||
dappMgr.on("disconnect", (reason, message) => {
|
||
if (reason === DisconnectReason.ProtocolMismatch) {
|
||
console.error("Protocol mismatch:", message);
|
||
} else {
|
||
console.log("Wallet disconnected:", reason, message);
|
||
}
|
||
relay.cleanup();
|
||
});
|
||
```
|
||
|
||
The `disconnect` event fires in two cases:
|
||
1. **Remote disconnect**: the wallet sent a `disconnect` message (any reason).
|
||
2. **Protocol mismatch**: `handleWalletReady` found no overlap between the dapp's and wallet's
|
||
`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"`,
|
||
it calls `onConnected()` which waits for key exchange and then sends a fresh `dapp_ready`. The
|
||
`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.
|
||
|
||
## signMessage
|
||
|
||
Proves the user controls a key, without a transaction. The signature is a standard "Bitcoin Signed
|
||
Message" signature, so anyone can verify it from the message, the signature and the address alone —
|
||
including from an OP_RETURN via a block explorer, or by pasting it into Electron Cash's Verify
|
||
Message.
|
||
|
||
```typescript
|
||
import { MODE_WALLET_CHOICE } from "@wizardconnect/core";
|
||
|
||
// 1. Check support before offering the feature.
|
||
if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) {
|
||
return; // hide the login button rather than offer one that fails
|
||
}
|
||
|
||
// 2. Get a single-use nonce from your server. See "Replay" below — this is not optional.
|
||
const nonce = await fetch("/auth/nonce").then((r) => r.text());
|
||
|
||
// 3. Ask the wallet. Omitting path/addressIndex lets the wallet pick the key,
|
||
// so no xpub is needed.
|
||
const result = await manager.signMessage({
|
||
message: `${location.host} wants you to sign in.\nnonce=${nonce}`,
|
||
userPrompt: "Sign in",
|
||
});
|
||
|
||
// 4. result is already verified. Send it to your server, which retires the nonce.
|
||
await fetch("/auth/verify", { method: "POST", body: JSON.stringify(result) });
|
||
```
|
||
|
||
### What is verified before it resolves
|
||
|
||
`signMessage()` never hands back what the wallet said unchecked. It rejects unless:
|
||
|
||
1. the signature recovers over the message that was actually sent;
|
||
2. `publicKey` is the key that signed it;
|
||
3. `address` is that key's address;
|
||
4. and, when the dapp named a path it can derive, the signer is **exactly** the key it asked for.
|
||
|
||
Without (4) a wallet could answer with a signature from any key it liked and a naive dapp would
|
||
accept it as the identity it asked about. It is the library's job, not each integrator's.
|
||
|
||
### keyBinding
|
||
|
||
```typescript
|
||
type MessageKeyBinding =
|
||
| { checked: true; path: PathName; addressIndex: number }
|
||
| { checked: false; reason: "wallet_chose_key" | "path_not_derivable" };
|
||
```
|
||
|
||
`checked: false` does **not** mean unverified — the signature always is. It means there was no
|
||
dapp-chosen key to compare against, so the proven address is the wallet's choice rather than the
|
||
dapp's selection:
|
||
|
||
- `wallet_chose_key` — the dapp omitted `path`/`addressIndex`. Normal for identity flows; the address
|
||
in the result *is* the identity.
|
||
- `path_not_derivable` — the dapp named an extension path (`stealth_scan`, say) that it has no
|
||
derivation rule for.
|
||
|
||
A dapp storing an identity should care about the difference. Naming a derivable path with no xpub
|
||
available is an error, not an unchecked result — call `signMessage` after `wallet_ready`.
|
||
|
||
### Replay — use the login challenge helpers
|
||
|
||
A signature proves key control over that exact text. It has **no freshness and no audience**: it is
|
||
valid forever, to everyone, and a captured one is replayable indefinitely. A login built on a bare
|
||
`signMessage` call works perfectly in every manual test and is a password that never expires.
|
||
|
||
So don't hand-roll the message. `@wizardconnect/core` provides a format that closes the three holes
|
||
that matter — single-use nonce, domain binding, expiry — and an API shaped so you cannot skip them.
|
||
|
||
**Server, issuing a challenge:**
|
||
|
||
```typescript
|
||
import { createLoginNonce } from "@wizardconnect/core";
|
||
|
||
const nonce = createLoginNonce(); // 16 random bytes, hex
|
||
await db.nonces.insert({ nonce, expiresAt: Date.now() + 300_000 });
|
||
return { nonce };
|
||
```
|
||
|
||
**Dapp, asking for the signature:**
|
||
|
||
```typescript
|
||
import { createLoginChallenge } from "@wizardconnect/core";
|
||
|
||
const message = createLoginChallenge({
|
||
domain: location.host,
|
||
nonce, // from the server — never generated here
|
||
expiresInSeconds: 300,
|
||
statement: "Sign in to view your positions.",
|
||
// address: known only in dapp_path mode; include it when you have it
|
||
});
|
||
|
||
const result = await manager.signMessage({ message, userPrompt: "Sign in" });
|
||
```
|
||
|
||
**Server, verifying:**
|
||
|
||
```typescript
|
||
import { verifyLoginChallenge } from "@wizardconnect/core";
|
||
|
||
const verification = await verifyLoginChallenge(result.message, result.signature, {
|
||
domain: "app.example.com",
|
||
// MUST be atomic — two replays arrive together and only one may be told true.
|
||
consumeNonce: (nonce) => db.nonces.deleteAndReport(nonce),
|
||
});
|
||
|
||
if (!verification.ok) return unauthorized(verification.reason);
|
||
session.user = verification.address; // only reachable through the ok branch
|
||
```
|
||
|
||
`domain` and `consumeNonce` are **required parameters**. There is no overload without them, because
|
||
verifying a login without single-use enforcement and audience binding is not a thing this API can
|
||
express. `createLoginChallenge` likewise refuses a nonce under 16 characters, and refuses a line
|
||
break in any field so no value can inject its own `Nonce:` line.
|
||
|
||
Checks run in a deliberate order — parse, domain, expiry, signature, **then** consume the nonce. A
|
||
bad signature therefore cannot burn a nonce the real user is still using.
|
||
|
||
The message format is deliberately similar to Sign-In With Ethereum, but it is **not** SIWE or
|
||
CAIP-122 and does not claim compatibility; there is no agreed SIWX profile for Bitcoin Cash. It is
|
||
plain text, so any wallet that can sign a message can sign it:
|
||
|
||
```
|
||
app.example.com wants you to sign in.
|
||
|
||
Address: bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h
|
||
Nonce: 8f3a21c0d4b57e69
|
||
Issued At: 2026-08-06T12:00:00Z
|
||
Expires At: 2026-08-06T12:05:00Z
|
||
Statement: Sign in to view your positions.
|
||
```
|
||
|
||
`Address` is omitted under `wallet_choice`, where the dapp does not yet know which key will answer —
|
||
pass the address from the result to `verifyLoginChallenge` instead. When the line *is* present, the
|
||
proof is self-describing (a third party reading the message alone sees which address was claimed) and
|
||
verification requires the recovered address to match it.
|
||
|
||
`createInMemoryNonceStore()` exists for development. It is per-process, so two servers behind a load
|
||
balancer will each honour the same signature once — use your database in production.
|
||
|
||
If you need a different message format, build it yourself and verify with
|
||
`verifyMessageSignatureForAddress` — but then the nonce, the domain and the expiry are all yours to
|
||
get right.
|
||
|
||
### Cancellation
|
||
|
||
Pass an `AbortSignal`, exactly as with `signTransaction`. There is no default timeout: a user
|
||
approving on a phone may take a while, and picking an arbitrary deadline for them is worse than
|
||
letting the dapp decide.
|
||
|
||
```typescript
|
||
const controller = new AbortController();
|
||
const promise = manager.signMessage({ message }, { signal: controller.signal });
|
||
// user closes the dialog:
|
||
controller.abort("User cancelled");
|
||
```
|
||
|
||
### Verifying elsewhere
|
||
|
||
`@wizardconnect/core` exports the verification helpers, so a server (or any third party) can check a
|
||
signature without a session:
|
||
|
||
```typescript
|
||
import {
|
||
verifyMessageSignatureForAddress,
|
||
messageSignatureAddress,
|
||
} from "@wizardconnect/core";
|
||
|
||
verifyMessageSignatureForAddress(message, signature, address); // the Electron Cash-equivalent check
|
||
messageSignatureAddress(message, signature); // recover the address instead
|
||
```
|
||
|
||
Accepts CashAddr with or without a prefix, and legacy base58. Rejects P2SH — no message signature can
|
||
prove control of a script hash.
|
||
|
||
---
|
||
|
||
## Using initiateDappRelay without DappConnectionManager
|
||
|
||
If you need lower-level control (e.g., in the test-cli), you can work directly with the
|
||
`RelayClient` and handle `wallet_ready` manually:
|
||
|
||
```typescript
|
||
const relay = initiateDappRelay(statusCallback, options);
|
||
|
||
relay.events.on("keyexchangecomplete", async (walletPubkey) => {
|
||
// Key exchange done — wait for relay client to be fully ready
|
||
while (!relay.client.isKeyExchangeComplete()) {
|
||
await sleep(50);
|
||
}
|
||
|
||
relay.client.on("message", (msg) => {
|
||
if (isDappReadyMessage(msg)) { /* ... */ }
|
||
if (isWalletReadyMessage(msg)) { /* ... */ }
|
||
});
|
||
|
||
await relay.client.relay({ action: RelayMsgAction.DappReady, ... });
|
||
});
|
||
```
|
||
|
||
## Using with React
|
||
|
||
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).
|
||
|
||
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.
|