@neuraiproject/neurai-connect-wallet
v0.0.2
Published
Neurai Connect SDK for wallets: pairing, session handling, CAIP-122 auth messages and CACAO responses.
Maintainers
Readme
@neuraiproject/neurai-connect-wallet
The wallet side of Neurai Connect: pair by scanning a QR code, approve logins ("Sign in with Neurai") and answer dApp requests. The SDK never holds private keys — the application signs and hands the result back.
Specification: spec/auth.md, spec/session.md.
import { NeuraiConnectWallet, IdentityRegistry } from "@neuraiproject/neurai-connect-wallet";
import { buildCacao, buildCacaoPayload, formatAuthMessage, signatureTypeForAddress } from "@neuraiproject/neurai-connect-core";
import { sign } from "@neuraiproject/neurai-message"; // in a browser: "@neuraiproject/neurai-message/browser"
const wallet = await NeuraiConnectWallet.init({
relayUrl: "wss://relay.neurai.org/v1",
storage, // encrypted app storage
metadata: { name: "NeuraiWallet", url: "https://neurai.org" },
});
// Login: show domain, URI and statement, then sign the canonical CAIP-122 text.
wallet.on("auth_request", async ({ id, payload, verify }) => {
if (!(await ui.confirmLogin(payload, verify))) return wallet.rejectAuth(id);
const address = await ui.pickAddress(payload.addressPolicy); // identity or wallet address
const p = buildCacaoPayload(payload, payload.chains[0], address);
const cacao = buildCacao(p, { t: signatureTypeForAddress(address), s: sign(formatAuthMessage(p), privateKey, true) });
await wallet.approveAuth(id, { cacaos: [cacao] });
});
// dApp session
wallet.on("session_proposal", async ({ id, namespaces }) => {
await wallet.approveSession(id, { namespaces: { bip122: { chains, accounts, methods, events } } });
});
wallet.on("session_request", async ({ id, method, params, guard }) => {
if (guard?.blocked) return wallet.rejectRequest(id); // a sign-in message for another domain
const result = await ui.approveAndSign(method, params); // PIN or biometrics
await wallet.respondRequest(id, result);
});
await wallet.pair(uriFromScanner); // nc: from the QR code, or neuraiwallet://connect?uri=…The example signs for a legacy address (N… / t…). Each address type has its own neurai-message signer, and addressKind(address) from neurai-connect-core tells them apart:
| addressKind | Addresses | Signer (neurai-message 0.11) | s.t |
| --- | --- | --- | --- |
| legacy | N… / t… | sign(text, privateKey, true) | neurai-secp256k1-compact |
| ecdsa | nq1r… / tnq1r… | signECDSAWitnessMessage(text, privateKey, address) | neurai-secp256k1-compact |
| authscript | nc1p… / tnc1p… | signPQMessage(text, secretKey, publicKey, address) | neurai-ml-dsa-44 |
| pq | pq1z… / tpq1z… | signPQMessage(text, secretKey, publicKey, address) | neurai-ml-dsa-44 |
Always pass the address to the witness signers: witness v2 and v3 sign a hash bound to the address, so a signature made without it never verifies. Check the result with verifyMessage(text, address, signature) before sending it.
What the SDK enforces for you
- Expiry. A login request whose
exp,nbfor TTL has passed — or whose dates cannot be parsed — is rejected on the wire and never reachesauth_request; the reason arrives onauth_rejected.approveAuthchecks the window again and throws if the user took too long, so a stale request cannot be signed. - Malformed payloads. A
statementor aresourcesentry that is not a non-empty single-line string is rejected the same way, before the request is stored or shown: it could change the shape of the text the user signs. The dApp SDK refuses to publish such a payload too. - Cross-domain
signMessage. EverysignMessageis inspected. By default (signMessageGuard: "confirm") the request still reaches your handler withguardattached, butrespondRequestthrows unless you pass{ confirmedCrossDomain: true }after a reinforced confirmation. WithsignMessageGuard: "block"the SDK answers 4001 itself and only emitsrequest_blocked.
What the wallet must show
- Login: the domain and URI in large type, the time of the request, the statement, and the sentence "By approving, the browser that shows this QR code will be connected to your account".
verify.domainMatchesMetadatais false when the requester's metadata does not match the domain: warn. Never show a verification code, it does not protect against a relayed QR code. signMessage: the whole message. If it looks like a sign-in message for another domain, block it or require reinforced confirmation.sendTransfer: destination, amount and memo.signPsbt: decoded outputs, change and fee.- Every signature behind PIN or biometrics.
Per-domain identity addresses
IdentityRegistry computes the path (m/44'/coin'/101'/0/<index> derived from the canonical domain) and records the identities used, which the application backup MUST include: the sparse indexes are not rediscovered by a normal BIP44 scan. The application derives the key with @neuraiproject/neurai-key.
Sessions
sessions(), disconnect(topic), disconnectAll() ("log out everywhere"), extendSession, updateSession and emitSessionEvent back the session-management screen. Sessions last 7 days, persist across restarts and are restored on init(). The session_settled event fires whenever this wallet settles one (a proposal approved, or a one-click login), so a session list can redraw itself without tracking the approval calls.
Pairings
pairingList() is every pairing the wallet holds and pendingPairings() the subset still waiting for the site to send its proposal or its login request. Approving either marks the pairing active, so it leaves pendingPairings() while remaining in pairingList() — and init() resubscribes to every pairing in the list, active ones included. forgetPairing(topic) and forgetPairings() drop them: nobody is notified, because there is nothing settled to notify — the site simply never gets an answer. Use them to cancel a scan, and to empty a relay before moving to another one.
A session and a pairing are both topics on one relay. A wallet that changes relay while it holds either strands it: it resubscribes on the new server, where the site is not listening, and a later disconnect() publishes wc_sessionDelete where nobody reads it. Wallets that expose a relay setting SHOULD refuse the change until sessions() and pairingList() are both empty — pairingList(), not pendingPairings(), because a login that settled no session still leaves an active pairing that the next start would resubscribe elsewhere.
In a browser
@neuraiproject/neurai-key and @neuraiproject/neurai-message resolve to their Node builds by default; a web wallet must import their /browser subpaths. Use BrowserStorage from @neuraiproject/neurai-connect-core as the storage adapter, and never persist the mnemonic.
