@eternl/hub-wallet-sdk
v0.31.3
Published
Eternl Hub SDK for wallet developers.
Keywords
Readme
@eternl/hub-wallet-sdk
A composables-based SDK for wallet developers to integrate with Eternl Hub. It provides the wallet-side building blocks for secure device/dApp pairing, end-to-end-encrypted messaging, presence, and remote signing over NATS WebSocket transport.
The primary, high-level entry point is createWalletConnectorSession — it composes the symmetric pairing protocol from @eternl/hub-core and adds the wallet-role logic (responding to dApp apiCalls, account switching, presence). Lower-level composables (useNatsClient, useEncryption, useReplayProtection, …) are also exported for advanced or custom integrations.
Status: source-available under BUSL-1.1 (see License). The API is stabilizing toward a 1.0 release; while on
0.x, minor versions may carry breaking changes — check the CHANGELOG before upgrading.
Installation
npm install @eternl/hub-wallet-sdk
# or
pnpm add @eternl/hub-wallet-sdkIt depends on @eternl/hub-core and the modular @nats-io/*@3.x clients (@nats-io/nats-core, @nats-io/jetstream, @nats-io/kv), which are installed automatically as dependencies.
Runtime requirements
The SDK requires a secure WebCrypto environment (a modern browser or Node.js 18+). initHub() calls assertCryptoCapabilities() and throws if crypto.subtle / crypto.getRandomValues are unavailable. There is no Math.random() fallback anywhere in the SDK.
You can probe capabilities yourself:
import { checkCryptoCapabilities } from '@eternl/hub-wallet-sdk'
const caps = checkCryptoCapabilities()
// {
// hasSubtleCrypto: boolean
// hasRandomValues: boolean
// hasRandomUUID: boolean
// isSecure: boolean // hasSubtleCrypto && hasRandomValues
// }
if (!caps.isSecure) {
throw new Error('WebCrypto unavailable — SDK cannot run here')
}Limitations — module-level singleton state
Read this before designing your integration.
Several core modules hold process-wide singleton state, not per-instance state:
useNatsClient— one NATS connection, one set of callbacks, and one subscription map per process. CallinguseNatsClient()repeatedly returns handles onto the same underlying connection.configure()overwrites the previously-configured providers/callbacks. There is exactly one connection per process.useEncryption—getCurrentKeyPair()/getCurrentSessionKey()track a single "current" key pair / session key per process.generateKeyPair()andderiveSessionKeyHKDF()overwrite them.signingTransport—configureSigningTransport(...)sets one global config consumed by all signing composables.getWalletPresenceBeacon()— returns a process-wide singleton beacon (so all sessions share one lifecycle-dispatcher subscription).useReplayProtection/useSessionKeyCache— module-level caches keyed bypairId/deviceId.
Practical consequence: this SDK is designed for one wallet client per JS context (one browser tab / one Node process). It is not safe to run two independent, differently-configured wallet clients in the same process. Tests that need isolation should reset state via the exported test helpers (e.g. __resetWalletPresenceBeaconForTests, clearAllReplayCaches, clearAllCachedKeys) or run in separate worker processes.
createWalletConnectorSession itself returns an independent per-pairing object, but it composes the singleton NATS transport and (by default) the singleton presence beacon underneath.
Configuration
1. Initialize the SDK
import { initHub, isInitialized, getConfig } from '@eternl/hub-wallet-sdk'
initHub({ network: 'mainnet' })
// With options:
initHub({
network: 'preprod', // 'mainnet' | 'preprod' | 'preview'
hubUrl: 'wss://custom-hub.example.com/nats', // default: wss://hub.de-5.eternl.art/nats
storage: myStorage, // optional HubStorage adapter
storagePrefix: 'my-wallet', // optional localStorage key prefix
constants: { CONNECTION_TIMEOUT_MS: 20_000 }, // optional protocol-constant overrides
})
if (isInitialized()) {
console.log('network:', getConfig().network)
}2. Required NATS configuration — getNatsUrl + getAuthenticator
useNatsClient does not read the hub URL from initHub. You must call configure() with at least a getNatsUrl provider before connecting. Production brokers run deny-by-default ACLs, so you must also supply a getAuthenticator that mints a per-pairing JWT.
import { useNatsClient } from '@eternl/hub-wallet-sdk'
import { jwtAuthenticator } from '@nats-io/nats-core'
const nats = useNatsClient()
nats.configure({
// Required. Returns the broker WebSocket URL.
getNatsUrl: () => 'wss://hub.de-5.eternl.art/nats',
// Per-pairing JWT model (R1.5 / C1.e).
// Called immediately before EVERY connect attempt — including reconnects —
// so you can mint a fresh per-pairing CLIENTS user JWT (e.g. POST /nats/token
// on be-hub) each time. Returning `undefined` produces an anonymous
// connection, which only works against local-dev / pre-R1.2 brokers.
//
// YOU are responsible for refresh-before-expiry: when the JWT nears `exp`,
// call `disconnect()` then `connect()` to swap in a fresh token. The SDK
// re-invokes this provider on the new connect.
getAuthenticator: async () => {
const { jwt, seed } = await mintPerPairingToken() // your call to be-hub
return jwtAuthenticator(jwt, new TextEncoder().encode(seed))
},
// Optional lifecycle / error callbacks:
onConnected: () => console.log('connected'),
onDisconnected: () => console.log('disconnected'),
onError: (msg: string) => console.error('nats error:', msg),
// Broker rejected the whole connection (AUTHORIZATION_VIOLATION). The SDK
// does NOT auto-retry — clear your cached JWT, then either mint a fresh one
// and call retry(), or surface a re-pair prompt.
onPairRevoked: () => clearCachedJwtAndPromptRepair(),
// Broker rejected a publish/subscribe (PERMISSIONS_VIOLATION). The
// connection stays up; this fires per-subject for ACL denials.
onPermissionsViolation: (subject?: string) => console.warn('ACL denied:', subject),
})
await nats.connect()Quick Start
A minimal end-to-end flow: connect, confirm the connection is live, then run a wallet connector session for a pairing.
import {
initHub,
useNatsClient,
configureSigningTransport,
createWalletConnectorSession,
type ApiCallResult,
} from '@eternl/hub-wallet-sdk'
// 1. Init
initHub({ network: 'mainnet' })
// 2. Configure + connect transport
const nats = useNatsClient()
nats.configure({
getNatsUrl: () => 'wss://hub.de-5.eternl.art/nats',
getAuthenticator: async () => myJwtAuthenticator(), // see "Required NATS configuration"
})
await nats.connect()
// connectionStatus() is a getter, not getConnectionStatus()
if (nats.connectionStatus() !== 'connected') {
throw new Error(`NATS not connected: ${nats.connectionStatus()}`)
}
// 3. Run a wallet connector session for an established pairing.
// `encryption` and `transport` here implement hub-core's SessionEncryptionApi
// and Transport — typically built from the pairing's derived session key and
// the NATS client. See "WalletConnectorSession" below.
const session = createWalletConnectorSession({
pairId, // string — the established pairing id
encryption, // SessionEncryptionApi (from @eternl/hub-core)
transport, // Transport (from @eternl/hub-core)
grantedPermissions, // Permission[]
walletName: 'My Wallet',
apiCallHandler: async ({ method, args, accountId }): Promise<ApiCallResult> => {
// Invoke the underlying CIP-30 method and return its result.
const result = await myWallet.invoke(method, args, accountId)
return { success: true, result }
},
})
session.on('apiCallReceived', ({ method }) => console.log('dApp called', method))Primary API — WalletConnectorSession
createWalletConnectorSession(options) builds a wallet-role session over a single pairing. It owns inbound apiCall dispatch, account-switch handling, access/consent state, and presence. The exported WalletConnectorSession class is a thin wrapper that returns the factory result.
Options (WalletConnectorSessionOptions)
interface WalletConnectorSessionOptions {
pairId: string
encryption: SessionEncryptionApi // from @eternl/hub-core
transport: Transport // from @eternl/hub-core
grantedPermissions: Permission[] // enforced for signing-method ACLs
requireConsent?: boolean // default false → auto-grants all categories on start
autoEstablishAllowed?: boolean // default true
walletName?: string
apiCallHandler: ApiCallHandler // REQUIRED
accountSwitchHandler?: AccountSwitchHandler
onPeerDisconnect?: (reason: string) => void
onPeerPairingRemoved?: (reason: string) => void
onError?: (err: Error) => void
// Opt-in raw-stream presence (Option C):
presenceC?: { deviceId: string; tabId: string; getNc: () => NatsConnection | null }
presenceBeacon?: WalletPresenceBeaconApi // override singleton (tests only)
}
type ApiCallResult =
| { success: true; result: unknown }
| { success: false; errorCode: string; errorMessage: string }
type ApiCallHandler = (params: {
method: string
args: unknown[]
accountId: string
requestId: string
}) => Promise<ApiCallResult>
type AccountSwitchHandler = (params: { accountId: string }) => Promise<boolean>Returned API (WalletConnectorSessionApi)
const session = createWalletConnectorSession(options)
session.pairId // string
session.accessGranted // boolean
session.grantedCategories // CIP30Category[] (copy)
session.grantedPermissions // readonly Permission[]
// Consent (only needed when requireConsent: true; otherwise auto-granted on start)
await session.grantAccess(['core', 'governance', 'extension'], /* autoEstablishAllowed */ true)
await session.denyAccess('User declined')
// Push account changes to the dApp
await session.notifyAccountsChanged(accounts, activeAccountId, 'updated')
// Presence
await session.requestPresenceCheck(/* timeoutMs? */ 5000) // Promise<boolean>
await session.requestPresenceCSnapshot() // Option C only; else resolves null
await session.notifyOnline()
await session.notifyOffline('tab hidden')
// Teardown
await session.disconnect('Wallet disconnected') // sends DISCONNECT, then disposes
await session.remove('Pairing removed') // sends PAIRING_REMOVED, then disposes
session.dispose() // local cleanup, no message
// Events (returns an unsubscribe fn)
const off = session.on('apiCallReceived', ({ method, accountId, requestId }) => { /* … */ })
session.on('accountSwitchRequested', ({ accountId }) => { /* … */ })
session.on('pingReceived', ({ timestamp }) => { /* … */ })
session.on('heartbeatReceived', ({ timestamp }) => { /* … */ })
session.on('peerPresence', ({ status, reason, sentAt }) => { /* … */ })
session.on('peerPresenceC', (event) => { /* … */ }) // Option C only
session.on('accessStateChange', ({ granted, reason }) => { /* … */ })
session.on('error', (err) => { /* … */ })
off()Notes:
- With
requireConsentomitted/false, the session auto-grants['core','governance','extension']on start. - When access is not granted (or the method's category is not granted), inbound
apiCalls are rejected witherrorCode: '-3'before yourapiCallHandlerruns. - A handler that throws is surfaced to the dApp as
errorCode: '-2'. - The session implements
Symbol.dispose, sousing session = createWalletConnectorSession(...)works where supported.
Remote signing (requester side)
useSigningRequestSender lets a wallet ask another paired instance to sign a transaction. It requires configureSigningTransport(...) to be called once at boot, and an active NATS connection.
import {
configureSigningTransport,
useSigningRequestSender,
} from '@eternl/hub-wallet-sdk'
// Configure once, globally:
configureSigningTransport({
getDeviceInfo: () => myDeviceInfo,
getMyDeviceId: () => myDeviceId,
getMyPeerId: () => myPeerId,
getSessionKey: (deviceId) => sessionKeysByDevice[deviceId], // base64 raw key | undefined
getPairCryptoContext: async (deviceId) => ({
pairingId,
pinAadBytes, // Uint8Array — first 8 bytes of SHA-256(pin)
}),
getNextSeq: (pairingId) => nextSeqFor(pairingId), // bigint, monotonic per pairing
})
const { requestRemoteSigning } = useSigningRequestSender()
const result = await requestRemoteSigning(targetInstance, transactionCBOR, {
walletPubKeyHash: 'sha256-hex-of-wallet-pubkey',
// walletId?: explicit id to skip the pubKeyHash lookup
// timeoutMs?: overrides SIGNING_TIMEOUT_MS (default 120_000)
})
// result.status: 'idle' | 'requesting' | 'waiting' | 'approved' | 'rejected' | 'timeout' | 'error'
if (result.status === 'approved') {
console.log('signed tx:', result.signedTxCBOR, 'witness:', result.witnessSet)
} else {
console.error('signing failed:', result.error)
}For the responder side, see useSigningRequestHandler (decode/decrypt inbound signing.request.encrypted) and useSigningResponseSender (publish the encrypted response).
Presence beacon & raw-stream presence
import {
getWalletPresenceBeacon,
createWalletPresenceCClient,
} from '@eternl/hub-wallet-sdk'
// Process-wide beacon — fans tab-visibility / page-unload transitions to all
// registered sessions. WalletConnectorSession registers itself automatically.
const beacon = getWalletPresenceBeacon()
console.log('registered sessions:', beacon.size)
// Standalone raw-stream (Option C) presence client (usually you'd use the
// session's `presenceC` option instead of building this directly):
const presenceC = createWalletPresenceCClient({
pairId,
deviceId,
tabId,
getNc: () => useNatsClient().getNatsConnection(), // live nats.ws connection or null
onPeerEvent: (event) => console.log('peer presence:', event.classification),
})
presenceC.start()
// … later
presenceC.stop()Lower-level composables
useNatsClient
One process-wide NATS WebSocket client. Status is read via getters, and request/response uses subscribeAndRequest (there is no request() method).
import { useNatsClient } from '@eternl/hub-wallet-sdk'
const nats = useNatsClient()
nats.configure({ getNatsUrl: () => 'wss://hub.de-5.eternl.art/nats', getAuthenticator })
await nats.connect()
// State getters
nats.connectionStatus() // 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
nats.isConnected() // boolean
nats.errorMessage() // string
nats.reconnectAttempts() // number
// Subscribe (JSON-parsed + schema-validated). Returns a Subscription | null.
const sub = nats.subscribe('hub.device.peer-id.message', (data) => {
console.log('received:', data)
})
// Raw subscribe (no JSON parse/validation) — returns { unsubscribe } | null
const rawSub = nats.subscribeRaw('presence_out.pair.dapp', (bytes: Uint8Array) => { /* … */ })
// Publish
await nats.publish('hub.device.peer-id.message', { type: 'hello' })
await nats.publishRaw('subject', new Uint8Array([1, 2, 3]))
// Request/response (unique reply subject per call)
const res = await nats.subscribeAndRequest<MyResponse>(
'hub.responses', // responseSubject (a unique suffix is appended)
'hub.requests', // requestSubject
{ query: 'status' }, // requestData (a `replyTo` field is injected)
{ timeout: 5000 },
)
if (res.success) console.log(res.data)
else console.error(res.error)
// Cleanup
nats.unsubscribe('hub.device.peer-id.message')
nats.unsubscribeAll()
await nats.disconnect()useEncryption
ECDH (P-256) + HKDF-SHA256 session-key derivation and AES-256-GCM AEAD, interoperable with @eternl/hub-core v2. There is no deriveSharedSecret; the v2 pipeline is deriveSharedSecretRaw → deriveSessionKeyHKDF. Encryption is PIN-bound: every frame's AAD is utf8(pairingId) || SHA-256(pin)[:8] || be64(seq).
import { useEncryption } from '@eternl/hub-wallet-sdk'
const enc = useEncryption()
// Handshake
const keyPair = await enc.generateKeyPair() // KeyPair | null
const myPubB64 = await enc.exportPublicKey(keyPair!.publicKey) // string | null
const peerPub = await enc.importPublicKey(peerPubB64) // CryptoKey | null
// v2 derivation
const shared = await enc.deriveSharedSecretRaw(keyPair!.privateKey, peerPub!) // Uint8Array | null
const sas6 = await enc.deriveSAS6(shared!, pin, pairingId) // 6-digit string for UI confirm
const session = await enc.deriveSessionKeyHKDF(shared!, pairingId, pin) // SessionKey | null
// session.key — non-extractable CryptoKey; session.raw — base64 raw key (persist with care)
// Encrypt — pass a CryptoKey and a context { pairingId, pin | pinAadBytes, seq }
let seq = 1n
const payload = await enc.encryptJSON(
{ hello: 'world' },
session!.key,
{ pairingId, pin, seq }, // OR { pairingId, pinAadBytes, seq } for PIN-less callers
)
// Decrypt — seq is read from the payload, so the context omits it
const plain = await enc.decryptJSON<{ hello: string }>(
payload!,
session!.key,
{ pairingId, pin }, // OR { pairingId, pinAadBytes }
)
// Re-import a persisted raw key as a non-extractable CryptoKey
const imported = await enc.importSessionKey(session!.raw) // CryptoKey | null
enc.cleanup()EncryptedPayload is a v2 envelope: { version: 2, iv, authTag, encrypted, algorithm, timestamp, messageId, pairingId, seq } (where seq is a decimal string). Decrypt hard-fails on any non-v2 payload.
useReplayProtection
In-memory, per-pairId message de-duplication with a TTL window. checkAndRecordMessage is synchronous and returns { valid, error? } (there is no .reason field and nothing to await).
import {
generateMessageId,
checkAndRecordMessage,
clearReplayCache,
} from '@eternl/hub-wallet-sdk'
const messageId = generateMessageId() // 'msg_…'
const timestamp = new Date().toISOString()
// Signature: checkAndRecordMessage(pairId, messageId, timestamp)
const check = checkAndRecordMessage('pair-id-123', messageId, timestamp)
if (!check.valid) {
console.warn('rejected:', check.error) // e.g. 'Replay attack detected: duplicate messageId'
}
clearReplayCache('pair-id-123')(For naming compatibility, checkAndRecordMessage is also re-exported as checkAndRecordMessageAsync — it is still synchronous despite the name.)
useSessionKeyCache
Caches non-extractable CryptoKeys keyed by deviceId. Note importSessionKey takes deviceId first, then the base64 raw key.
import {
importSessionKey,
getCachedKey,
hasCachedKey,
clearCachedKey,
getCacheStats,
} from '@eternl/hub-wallet-sdk'
const key = await importSessionKey(deviceId, rawBase64) // (deviceId, rawBase64) → CryptoKey | null
if (hasCachedKey(deviceId)) {
const cached = getCachedKey(deviceId) // CryptoKey | null
}
const stats = getCacheStats() // { size: number; deviceIds: DeviceId[] }
clearCachedKey(deviceId)useMessageAuth
HMAC-SHA256 authentication over RFC-8785-canonicalized messages, keyed by the base64 raw session key string.
import {
signMessage,
verifyMessage,
createSignedMessage,
verifyAndUnwrap,
} from '@eternl/hub-wallet-sdk'
const sig = await signMessage({ type: 'presence', online: true }, sessionKeyRaw) // string | null
const ok = await verifyMessage({ type: 'presence', online: true, signature: sig! }, sessionKeyRaw)
const signed = await createSignedMessage({ type: 'presence', online: true }, sessionKeyRaw) // T & { signature } | null
const orig = await verifyAndUnwrap(signed!, sessionKeyRaw) // T | null (null if invalid)useNatsValidation
Message-structure validation and type guards.
import {
validateNatsMessage,
isValidEncryptedPayload,
parseAndValidatePayload,
} from '@eternl/hub-wallet-sdk'
const v = validateNatsMessage(message) // { valid: boolean; error?: string; type?: string }
if (isValidEncryptedPayload(payload)) { /* … */ }
const parsed = parseAndValidatePayload(jsonString)Storage
The HubStorage interface uses getItem / setItem / removeItem (with an optional clear), and supports both sync (localStorage) and async (IndexedDB) backends.
import {
createLocalStorage,
createMemoryStorage,
isLocalStorageAvailable,
type HubStorage,
} from '@eternl/hub-wallet-sdk'
interface HubStorage {
getItem(key: string): string | null | Promise<string | null>
setItem(key: string, value: string): void | Promise<void>
removeItem(key: string): void | Promise<void>
clear?(): void | Promise<void>
}
// Built-in adapters
initHub({ network: 'mainnet', storage: createLocalStorage('my-wallet') })
initHub({ network: 'mainnet', storage: createMemoryStorage() })
// Custom (e.g. IndexedDB) adapter
const customStorage: HubStorage = {
getItem: (key) => myDB.get(key),
setItem: (key, value) => myDB.set(key, value),
removeItem: (key) => myDB.delete(key),
}
initHub({ network: 'mainnet', storage: customStorage })If no storage is provided, initHub uses localStorage when available and falls back to in-memory storage otherwise.
Utilities
Canonicalization (RFC 8785)
import { canonicalize, createSigningPayload } from '@eternl/hub-wallet-sdk'
canonicalize({ b: 2, a: 1 }) // '{"a":1,"b":2}'
createSigningPayload(message, ['signature']) // canonical string with fields excludedSecure IDs
import { generateSecureId, generateSecureMessageId, assertCryptoCapabilities } from '@eternl/hub-wallet-sdk'
assertCryptoCapabilities() // throws if WebCrypto is unavailable
const id = generateSecureId() // 32-char hex (derived from crypto.randomUUID / getRandomValues)
const mid = generateSecureMessageId() // 'msg_…' (alias of generateMessageId)Protocol constants
All protocol constants are exported as getter functions that return the resolved value (defaults, or overrides passed via initHub({ constants: … })):
import {
CONNECTION_TIMEOUT_MS, // () => 10_000
SIGNING_TIMEOUT_MS, // () => 120_000
REPLAY_PROTECTION_TTL_MS, // () => 300_000
MAX_PAYLOAD_SIZE_BYTES, // () => 1_048_576
PRESENCE_HEARTBEAT_INTERVAL_MS, // () => 5_000
DEFAULT_HUB_URL, // 'wss://hub.de-5.eternl.art/nats' (string constant)
} from '@eternl/hub-wallet-sdk'
const timeout = CONNECTION_TIMEOUT_MS() // call themProtocol types & permissions
The SDK re-exports the full set of protocol types from @eternl/hub-core and its own modules, plus the Permission enum and AccountInfo:
import type {
DeviceId, PeerId, PairId, PairingId,
DeviceInfo, PairedInstance, WalletRegistryEntry,
SigningRequestMessage, EncryptedSigningRequestMessage,
EncryptedPayload, SessionKey, KeyPair,
ValidationResult, ReplayValidationResult,
AccountInfo,
} from '@eternl/hub-wallet-sdk'
import { Permission } from '@eternl/hub-wallet-sdk'
const granted = [Permission.ADDRESS_READ, Permission.SIGN_TX, Permission.SIGN_DATA]Security model (summary)
- End-to-end encryption — AES-256-GCM with random 12-byte IVs and a 16-byte auth tag.
- PIN-bound session keys —
HKDF-SHA256over the ECDH (P-256) shared secret, salted withSHA-256(utf8(pin) || pairingId); the AAD bindspairingId,SHA-256(pin)[:8], and a monotonicseq. Without the PIN the session key cannot be derived from the ECDH transcript alone. - SAS6 — a 6-digit short authentication string for out-of-band pairing confirmation.
- Replay protection — per-
pairIdmessage de-duplication with a TTL window and clock-skew tolerance. - HMAC authentication — HMAC-SHA256 over RFC-8785-canonicalized messages.
- Payload validation — size limits and message-schema checks on inbound NATS messages.
- Strict v2 wire — pre-v2 encrypted payloads are hard-rejected (no silent downgrade).
License
Business Source License 1.1 (BUSL-1.1). See LICENSE.
The Licensor is Tastenkunst GmbH. Additional Use Grant: None — you may copy, modify, and make non-production use of this package. Production/commercial use requires a separate license from Tastenkunst. On the Change Date (the fourth anniversary of each version's first publication) that version converts to GPL-2.0-or-later.
Contact: https://tastenkunst.com/
