@eternl/hub-wallet-sdk (英语)
一个基于可堆肥的SDK,供钱包开发者与Eternl Hub集成. 它为安全设备/dApp配对,端到端加密消息,存在,以及NATS WebSocket传输上的远程签名提供了钱包侧构件.
主要的高级切入点是 createWalletConnectorSession ——由它组成对称配对协议 @eternl/hub-core 并添加钱包作用逻辑(对应 dApp) apiCalls,账户切换,存在). 低级堆肥(useNatsClient, (中文(简体) ), useEncryption, (中文(简体) ), useReplayProtection,.)也为高级或自定义集成出口.
状态 : bUSL-1.1项下的源数据(见 许可证) (中文(简体) ). API正稳定在1.0发布
0.x,小版本可能包含断开的更改——检查 变化 升级前.
安装
npm install @eternl/hub-wallet-sdk
# or
pnpm add @eternl/hub-wallet-sdk
这取决于 @eternl/hub-core 和模块 @nats-io/*@3.x 客户(@nats-io/nats-core, (中文(简体) ), @nats-io/jetstream, (中文(简体) ), @nats-io/kv)作为依赖性自动安装.
运行时间要求
SDK需要安全的WebCrypto环境(现代浏览器或Node.js 18+). initHub() 电话 assertCryptoCapabilities() 并投掷,如果 crypto.subtle 编号 : crypto.getRandomValues 无法使用。 没有 Math.random() 在SDK的任何地方都掉头.
你可以自己探测能力:
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')
}
限制——模块级单子状态
在设计您的整合之前先读一下.
几个核心模块 整个进程单子状态,而不是逐个状态:
useNatsClient——一个NATS连接,一组回调,每个过程一个订阅地图. 通话useNatsClient()反复返回到 不变 基础连接.configure()覆盖先前配置的提供者/召回 。 每个过程只有一个连接.useEncryption—— 说getCurrentKeyPair()编号 :getCurrentSessionKey()跟踪单个“当前”密钥对 / 会话密钥.generateKeyPair()财务报告和审定财务报表deriveSessionKeyHKDF()覆盖他们.signingTransport—— 说configureSigningTransport(...)设置一个全局配置, 由所有签名可混凝土消耗 .getWalletPresenceBeacon()——返回全过程的单通信标(因此所有会话都共享一个生命周期-发送器订阅).useReplayProtection编号 :useSessionKeyCache— 模块级缓存按键pairId编号 :deviceId。 。 。 .
实际后果: SDK 设计用于 每个 JS 上下文一个钱包客户端 (一个浏览器标签/一个节点进程). 没错 没有 安全运行两个独立,不同配置的钱包客户端在同一过程中. 需要隔离的测试应当通过导出测试帮助器(例如. __resetWalletPresenceBeaconForTests, (中文(简体) ), clearAllReplayCaches, (中文(简体) ), clearAllCachedKeys)或以不同的工人流程运行.
createWalletConnectorSession 本身返回独立的每平方对象,但它构成单通NATS传输和(默认情况下)下单通存在信标.
配置
1. 初始化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. 所需NATS配置 - getNatsUrl + 键 getAuthenticator
useNatsClient 已经 没有 从 initHub您必须拨打 configure() 至少有一个 getNatsUrl 连接前提供方 。 生产经纪人以违约方式进行交易,所以你还必须提供 getAuthenticator 那个薄荷糖 a 每笔捐款。 。 。 .
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()
快速启动
最小端到端流:连接,确认连接是活的,然后运行钱包连接器会话进行对接.
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))
API-钱包连接会议
createWalletConnectorSession(options) 在一个对齐上构建一个钱包作用会话 。 它拥有入境权 apiCall 调度、账户交换处理、访问/同意状态和存在。 导出 WalletConnectorSession 类是薄薄的包装器,可以返回工厂的结果.
选项( E)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>
返回的 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()
注释:
- 与
requireConsent删除/false自动赠款['core','governance','extension']开始. - 当未获准进入(或该方法的类别未获准)时,入境
apiCalls 以errorCode: '-3'{\fn黑体\fs20\shad2\2ah82\3ah20\4ah33\fscx95\3ch592001\be1}在你之前 {\fn黑体\fs20\shad2\2ah82\3ah20\4ah33\fscx95\3ch592001\be1}你已经..apiCallHandler运行. - 扔出一个处理器被浮出DApp
errorCode: '-2'。 。 。 . - 会议执行
Symbol.dispose这样using session = createWalletConnectorSession(...)在支持的地方工作.
远程签名( 请求方)
useSigningRequestSender 让钱包要求另一个对齐实例来签名交易。 这需要 configureSigningTransport(...) 和活跃的NATS连接.
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)
}
反应方请看 useSigningRequestHandler (解码/解码输入) signing.request.encrypted和 useSigningResponseSender (发布加密响应).
存在信标和原始流存在
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()
低级可堆肥
使用 Nats 客户端
一个全流程的NATS WebSocket客户端. 状态通过 获取器,以及请求/答复用途 subscribeAndRequest (没有) request() 页:1.
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()
使用复制
ECDH(P-256) + HKDF-SHA256 会话键衍生和AES-256-GCM AEAD, 与 @eternl/hub-core v2. 现有 没有 deriveSharedSecret; v2 管道为 deriveSharedSecretRaw → deriveSessionKeyHKDF加密为 PIN 输入: 每个帧的 AAD 是 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 是一个 v2 信封 : { version: 2, iv, authTag, encrypted, algorithm, timestamp, messageId, pairingId, seq } (何处) seq 是一个小数字符串。 解密任何非v2载荷的硬故障.
重放保护
记忆中,每..pairId 以 TTL 窗口消除信件复制. checkAndRecordMessage 实值 同步 返回时 { valid, error? } (没有) .reason 和无所等待.
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')
(关于命名兼容性, checkAndRecordMessage 也作为 checkAndRecordMessageAsync ——尽管有名字,它仍然同步. )
使用会话密钥缓存
切片不可提取 CryptoKey键入 deviceId说明 importSessionKey 带 deviceId 第一个,然后是基础64生键.
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)
使用MessageAuth 数据
HMAC-SHA256认证在 RFC-8785-canonicalized messages 上,由 Base64 生会话密钥字符串键入.
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)
使用 Nats 校正
消息结构验证和类型守护.
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)
存储
这个 HubStorage 界面使用 getItem 编号 : setItem 编号 : removeItem (带有可选内容) clear),并同时支持同步(localStorage)和Async(IndexedDB)后端.
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 })
如果没有提供存储, initHub 可用时使用本地Storage,否则将返回内存.
公用事业
加农化(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 excluded
安全身份证
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)
协议常数
所有协议常数导出为 获取函数 返回已解析值(默认值或通过 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 them
协议类型和权限
SDK从中再导出全套协议类型 @eternl/hub-core 及其自己的模块,加上 Permission 页:1 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]
安保模式(摘要)
- 端到端加密 ——AES-256-GCM 有随机的12字节IV和16字节Auth标记.
- PIN 绑定会话键 —— 说
HKDF-SHA256(P-256)共同的秘密,与SHA-256(utf8(pin) || pairingId); AAD 绑定pairingId, (中文(简体) ),SHA-256(pin)[:8]单调seq. 没有PIN,会议键无法仅取自欧洲人权法院的记录. - 标准6 ——6位数的短认证字符串,用于波段外配对确认.
- 重放保护 每个
pairId以 TTL 窗口和时钟- swew 容忍来消除信件重复 . - HMAC 认证 ——HMAC-SHA256上传了RFC-8785卡通化消息.
- 有效载荷验证 ——大小限制和对入境的NATS消息的检查.
- 严格 v2 线 ——预v2加密有效载荷被硬拒绝(无静降级).
许可证
商业来源许可证1.1(BUSL-1.1) (中文(简体) ). 见 自由主义。 。 。 .
许可是Tastenkunst GmbH。 额外使用赠款 : None - 你可以复制、修改和制作 非生产 使用此软件包。 生产/商业用途需要从塔斯滕昆斯特获得单独的许可证。 在"更改日期"(每个版本首次出版的四周年)上,该版本会转换为GPL-2.0-或-后期.