@eternl/hub-dapp-sdk
v0.31.3
Published
Eternl Hub SDK for dApp developers.
Keywords
Readme
@eternl/hub-dapp-sdk
Connect your dApp to Cardano wallets through the Eternl Hub — no browser extension required. Pairing happens over a relayed, end-to-end-encrypted channel using a short connection code (or QR / deep link), and the resulting session exposes a CIP-30 compatible API plus a few Eternl extensions.
The SDK ships a framework-agnostic core plus thin, state-only helpers for React and Vue.
Installation
npm install @eternl/hub-dapp-sdk
# or
pnpm add @eternl/hub-dapp-sdk
# or
yarn add @eternl/hub-dapp-sdkreact and vue are optional peer dependencies — install only the one your app uses. The core entry point (@eternl/hub-dapp-sdk) needs neither.
How connecting works
There is no EternlConnect.connect() facade. You drive the flow yourself with two core functions:
| Function | Role | Returns |
| --- | --- | --- |
| createDAppConnection(options) | Offerer. The dApp generates a code and waits for a wallet to join. | a DAppConnectionHandle ({ code, dappId, expiresAt, channel, waitForWallet, cancel }) |
| joinWithCode(code, options) | Verifier. The dApp enters a code shown by the wallet. | a Promise<EternlSession> |
Most dApps use createDAppConnection: show handle.code to the user, then await handle.waitForWallet() to receive an EternlSession.
The security model: you MUST display the SAS6
During waitForWallet, the connection reaches a status: 'sas_ready' step that carries a 6-digit Short Authentication String (sas6). Your dApp must render sas6 to the user so they can compare it against the digits shown in their wallet.
If the two codes match, no man-in-the-middle substituted the handshake keys. This comparison is the core defense of the pairing protocol — do not skip rendering it. The same sas6 is also present on the subsequent 'confirmed' event.
Quick Start (framework-agnostic core)
import { createDAppConnection } from '@eternl/hub-dapp-sdk';
import type { ConnectionStatusEvent, EternlSession } from '@eternl/hub-dapp-sdk';
async function connect(): Promise<EternlSession> {
const handle = await createDAppConnection({
appName: 'My DApp',
appUrl: 'https://mydapp.com',
appIcon: 'https://mydapp.com/icon.png',
permissions: ['read', 'sign'],
network: 'mainnet',
// Required against production hubs — see "NATS authentication" below.
getAuthenticator: myGetAuthenticator,
});
// 1. Show the connection code to the user. They enter it in their wallet.
console.log('Enter this code in Eternl:', handle.code);
console.log('Code expires at:', new Date(handle.expiresAt).toLocaleTimeString());
// 2. Wait for the wallet. The onStatus callback drives your UI, and it is
// where the SAS6 verification step surfaces.
const session = await handle.waitForWallet((event: ConnectionStatusEvent) => {
switch (event.status) {
case 'waiting_for_wallet':
console.log('Waiting for a wallet to enter the code…');
break;
case 'wallet_connected':
console.log('Wallet connected, exchanging keys…');
break;
case 'sas_ready':
// SECURITY-CRITICAL: render event.sas6 to the user so they can compare
// it with the 6 digits shown in their wallet before continuing.
console.log('Verify these digits match your wallet:', event.sas6);
break;
case 'awaiting_user':
console.log(`${event.walletName ?? 'Wallet'}: user is selecting accounts…`);
break;
case 'confirmed':
console.log('Connection confirmed.');
break;
case 'error':
case 'cancelled':
case 'timeout':
console.log('Connection ended:', event.status, event.message);
break;
}
});
// 3. Use the session (CIP-30 + Eternl extensions).
const networkId = await session.getNetworkId();
const ada = await session.getAdaBalance();
console.log('Connected:', session.activeAccount.name, networkId, `${ada} ADA`);
return session;
}To abort before a wallet joins, call handle.cancel().
Joining a code shown by the wallet
If your flow instead has the wallet present a code, use joinWithCode:
import { joinWithCode } from '@eternl/hub-dapp-sdk';
const session = await joinWithCode(userEnteredCode, {
appName: 'My DApp',
permissions: ['read', 'sign'],
getAuthenticator: myGetAuthenticator,
});Rendering a QR code / deep link
handle.code is the canonical thing to show. If you want to encode it as a scannable QR or a mobile deep link, build a PairingV2Payload and use the encoders:
import { generateQRData, generateDeepLink } from '@eternl/hub-dapp-sdk';
import type { PairingV2Payload } from '@eternl/hub-dapp-sdk';
const payload: PairingV2Payload = {
v: 2,
type: 'dapp',
pin: handle.code,
expiresAt: handle.expiresAt,
};
const qrString = generateQRData(payload); // "eternl-pair:<base64url(JSON)>" — feed to any QR encoder
const deepLink = generateDeepLink(payload); // "eternl://pair?p=<base64url(JSON)>"NATS authentication (required in production)
Pairing traffic flows over a hardened NATS broker that denies anonymous connections by default. You must supply an authenticator:
- Provide
getAuthenticator— anasync () => Authenticator | undefinedfrom@nats-io/nats-core. - If
getAuthenticatoris omitted (or resolves toundefined) andunsafeAllowAnonymousNatsis not set, the connect call throwsEternlConnectErrorwithcode === ErrorCode.AUTH_REQUIRED.
import { createDAppConnection, EternlConnectError, ErrorCode } from '@eternl/hub-dapp-sdk';
try {
const handle = await createDAppConnection({
appName: 'My DApp',
getAuthenticator: async () => myAuthenticator, // from @nats-io/nats-core
});
// …
} catch (err) {
if (err instanceof EternlConnectError && err.code === ErrorCode.AUTH_REQUIRED) {
console.error('No NATS authenticator was supplied.');
}
}unsafeAllowAnonymousNats: true is an escape hatch for local/dev brokers only. Against production brokers an anonymous connection silently downgrades — the session looks paired but cannot publish — so the SDK refuses it by default.
Note: in standard production setups the SDK can mint a scoped token internally, so you may not need
getAuthenticatorat all. Confirm with your hub deployment which mode applies. Either way, the rule is: a real connection needs auth, or it throwsAUTH_REQUIRED.
React
The React helpers are state-only. useEternl() does not connect — it holds the session and exposes reactive state. Your app calls createDAppConnection / joinWithCode itself, then hands the resulting session to setSession.
useEternl() returns:
{
session: EternlSession | null;
error: EternlConnectError | null;
isConnected: boolean;
activeAccount: AccountInfo | null;
accounts: readonly AccountInfo[];
setSession: (session: EternlSession) => void; // install a connected session
disconnect: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
}The driver pattern — connect with the core, then setSession:
import { useState } from 'react';
import { useEternl, EternlConnectionBadge } from '@eternl/hub-dapp-sdk/react';
import { createDAppConnection } from '@eternl/hub-dapp-sdk';
import type { ConnectionStatusEvent } from '@eternl/hub-dapp-sdk';
function App() {
const { isConnected, activeAccount, setSession, disconnect } = useEternl();
const [code, setCode] = useState<string | null>(null);
const [sas6, setSas6] = useState<string | null>(null);
async function handleConnect() {
const handle = await createDAppConnection({
appName: 'My DApp',
permissions: ['read', 'sign'],
getAuthenticator: myGetAuthenticator,
});
setCode(handle.code); // show the code to the user
const session = await handle.waitForWallet((event: ConnectionStatusEvent) => {
if (event.status === 'sas_ready') {
// SECURITY-CRITICAL: show this so the user can compare with their wallet.
setSas6(event.sas6 ?? null);
}
});
setSession(session); // hand the connected session to the hook
setCode(null);
setSas6(null);
}
if (!isConnected) {
return (
<div>
<button onClick={handleConnect}>Connect Wallet</button>
{code && <p>Enter this code in Eternl: <strong>{code}</strong></p>}
{sas6 && <p>Verify these digits match your wallet: <strong>{sas6}</strong></p>}
</div>
);
}
return (
<div>
<p>Connected: {activeAccount?.name}</p>
<button onClick={disconnect}>Disconnect</button>
{/* Floating badge with account switcher + disconnect */}
<EternlConnectionBadge
position="top-right"
onAccountChange={(account) => console.log('Switched to:', account.name)}
onDisconnect={() => console.log('Disconnected')}
/>
</div>
);
}EternlConnectionBadge (React) props
interface EternlConnectionBadgeProps {
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; // default 'top-right'
className?: string;
style?: React.CSSProperties;
onAccountChange?: (account: AccountInfo) => void;
onDisconnect?: () => void;
}The badge reads its state from the same useEternl() singleton, so it renders the current account and offers an account switcher / disconnect once setSession has been called.
Vue
The Vue composable is likewise state-only — useEternl() does not connect. You drive the connection with the core functions and call setSession.
useEternl() returns (refs are reactive):
{
session: Readonly<Ref<EternlSession | null>>;
error: Readonly<Ref<EternlConnectError | null>>;
isConnected: ComputedRef<boolean>;
activeAccount: ComputedRef<AccountInfo | null>;
accounts: ComputedRef<readonly AccountInfo[]>;
setSession: (session: EternlSession) => void; // install a connected session
disconnect: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
}The driver pattern in a SFC:
<script setup lang="ts">
import { ref } from 'vue';
import { useEternl, EternlConnectionBadge } from '@eternl/hub-dapp-sdk/vue';
import { createDAppConnection } from '@eternl/hub-dapp-sdk';
import type { ConnectionStatusEvent } from '@eternl/hub-dapp-sdk';
const { isConnected, activeAccount, setSession, disconnect } = useEternl();
const code = ref<string | null>(null);
const sas6 = ref<string | null>(null);
async function handleConnect() {
const handle = await createDAppConnection({
appName: 'My DApp',
permissions: ['read', 'sign'],
getAuthenticator: myGetAuthenticator,
});
code.value = handle.code; // show the code to the user
const session = await handle.waitForWallet((event: ConnectionStatusEvent) => {
if (event.status === 'sas_ready') {
// SECURITY-CRITICAL: show this so the user can compare with their wallet.
sas6.value = event.sas6 ?? null;
}
});
setSession(session); // hand the connected session to the composable
code.value = null;
sas6.value = null;
}
</script>
<template>
<div v-if="!isConnected">
<button @click="handleConnect">Connect Wallet</button>
<p v-if="code">Enter this code in Eternl: <strong>{{ code }}</strong></p>
<p v-if="sas6">Verify these digits match your wallet: <strong>{{ sas6 }}</strong></p>
</div>
<div v-else>
<p>Connected: {{ activeAccount?.name }}</p>
<button @click="disconnect">Disconnect</button>
<!-- Floating badge with account switcher + disconnect -->
<EternlConnectionBadge
position="top-right"
@account-change="(account) => console.log('Switched to:', account.name)"
@disconnect="() => console.log('Disconnected')"
/>
</div>
</template>EternlConnectionBadge (Vue) props & events
- Props:
position('top-left' | 'top-right' | 'bottom-left' | 'bottom-right', default'top-right'),className(string). - Events:
account-change(emits the newAccountInfo),disconnect.
EternlSession reference
Once connected, the session is your CIP-30 surface plus a few Eternl extensions. All methods are async unless noted.
State (read-only properties)
| Property | Type | Notes |
| --- | --- | --- |
| pairId | string | 64-hex pairing identifier for this session. |
| transport | TransportType | Underlying transport in use. |
| expiresAt | Date | When the session expires. |
| isConnected | boolean | Transport connectivity. |
| walletOnline | boolean | Whether the paired wallet is currently reachable. |
| accessGranted | boolean | Whether the wallet granted this dApp access (consent layer, distinct from isConnected). |
| grantedCategories | ReadonlyArray<'core' \| 'governance' \| 'extension'> | Method categories the wallet granted. |
| grantedPermissions | readonly Permission[] | Granted CIP-30/CIP-95 permissions. |
| deviceInfo | WalletDeviceInfo \| null | Connected wallet's device info. |
| availableAccounts | readonly AccountInfo[] | All accounts shared by the wallet. |
| activeAccount | AccountInfo | The currently active account. |
CIP-30 core
session.getNetworkId(); // Promise<number>
session.getUtxos(amount?, paginate?); // Promise<string[] | null>
session.getBalance(); // Promise<string> (CBOR-encoded value)
session.getUsedAddresses(paginate?); // Promise<string[]>
session.getUnusedAddresses(); // Promise<string[]>
session.getChangeAddress(); // Promise<string>
session.getRewardAddresses(); // Promise<string[]>
session.signTx(txCbor, partialSign?); // Promise<string> (witness set / signed tx CBOR)
session.signData(address, payloadHex); // Promise<DataSignature>
session.submitTx(txCbor); // Promise<string> (tx hash)CIP-30 extensions
session.getExtensions(); // Promise<{ cip: number }[]>
session.getCollateral(params?); // Promise<string[] | null>CIP-95 governance
session.getPubDRepKey(); // Promise<string>
session.getRegisteredPubStakeKeys(); // Promise<string[]>
session.getUnregisteredPubStakeKeys(); // Promise<string[]>Eternl extensions
session.getAdaBalance(); // Promise<number> — balance in ADA, already parsed
session.requestPresenceCheck(); // Promise<boolean> — actively probe wallet reachability
session.switchAccount(accountId); // Promise<void>
session.disconnect(); // Promise<void> — tear down this session
session.remove(); // Promise<void> — disconnect and forget the pairingEvents — session.on(event, listener) returns an unsubscribe function:
const off = session.on('accountChange', (account) => { /* AccountInfo */ });
session.on('accountsChanged', (e) => { /* { accounts, activeAccountId } */ });
session.on('walletStatusChange', (e) => { /* { online: boolean } */ });
session.on('connectionChange', (e) => { /* { isConnected: boolean } */ });
session.on('accessGranted', (e) => { /* { grantedCategories, autoEstablishAllowed, walletName? } */ });
session.on('accessDenied', (e) => { /* { reason: string } */ });
session.on('peerPresenceC', (e) => { /* fast (~5s) presence event; requires enablePresenceC */ });
session.on('error', (err) => { /* Error */ });
off(); // unsubscribeThe peerPresenceC event only fires when you pass enablePresenceC: true in DAppConnectionOptions. It provides faster (~5s) offline detection than the legacy heartbeat path and runs additively alongside it.
DAppConnectionOptions
Shared by createDAppConnection and joinWithCode:
interface DAppConnectionOptions {
appName: string; // required — shown to the user
appUrl?: string; // defaults to window.location.origin
appIcon?: string;
permissions?: string[]; // e.g. ['read', 'sign'] (default)
network?: string; // defaults to 'mainnet'
hubUrl?: string; // NATS WebSocket URL; defaults to the public hub
hubRestUrl?: string; // Hub REST URL; defaults to the public hub
getAuthenticator?: () => Promise<Authenticator | undefined>; // from @nats-io/nats-core
unsafeAllowAnonymousNats?: boolean; // dev/local only — see "NATS authentication"
enablePresenceC?: boolean; // opt into fast raw-stream presence (peerPresenceC)
}Error handling
import { EternlConnectError, ErrorCode } from '@eternl/hub-dapp-sdk';
try {
const handle = await createDAppConnection({ appName: 'My DApp', getAuthenticator });
const session = await handle.waitForWallet();
} catch (error) {
if (error instanceof EternlConnectError) {
switch (error.code) {
case ErrorCode.AUTH_REQUIRED: /* no NATS authenticator supplied */ break;
case ErrorCode.TIMEOUT: /* code expired before a wallet joined */ break;
case ErrorCode.REJECTED: /* user rejected the connection */ break;
case ErrorCode.CANCELLED: /* connection was cancelled */ break;
case ErrorCode.TRANSPORT_ERROR: /* network / NATS error */ break;
default: console.error(error.code, error.message);
}
}
}Balance utilities
import {
parseLovelaceBalance,
lovelaceToAda,
formatAda,
parseAndFormatAda,
} from '@eternl/hub-dapp-sdk';
const lovelace = parseLovelaceBalance(await session.getBalance()); // CBOR → lovelace
const ada = lovelaceToAda(lovelace); // 1_000_000 → 1
const formatted = formatAda(lovelace); // "1.234567 ADA"
const display = parseAndFormatAda(await session.getBalance()); // CBOR → "1.234567 ADA"(session.getAdaBalance() does the parse-and-convert for you in a single call.)
Session persistence & paired wallets
The core also exports helpers to persist and restore connections without re-pairing:
import {
hasStoredSession,
getStoredSessionInfo,
restoreSession,
clearStoredSession,
getPairedWallets,
reconnectToWallet,
removePairedWallet,
clearPairedWallets,
} from '@eternl/hub-dapp-sdk';
if (hasStoredSession()) {
const session = await restoreSession({ getAuthenticator: myGetAuthenticator });
}
const wallets = getPairedWallets(); // PairedWallet[]
const session = await reconnectToWallet(wallets[0].walletId, { getAuthenticator: myGetAuthenticator });restoreSession and reconnectToWallet accept the same getAuthenticator / unsafeAllowAnonymousNats auth options described above, and likewise throw AUTH_REQUIRED without them.
Subpath exports
| Import path | Contents |
| --- | --- |
| @eternl/hub-dapp-sdk | Core: createDAppConnection, joinWithCode, generateQRData, generateDeepLink, session/paired-wallet helpers, Permission, EternlConnectError, ErrorCode, balance utilities, and all types. |
| @eternl/hub-dapp-sdk/react | useEternl, EternlConnectionBadge (state-only React helpers). |
| @eternl/hub-dapp-sdk/vue | useEternl, EternlConnectionBadge (state-only Vue 3 helpers). |
TypeScript
The package is fully typed. Real exported types include:
import type {
// Core connection
DAppConnectionOptions,
DAppConnectionHandle,
DAppSession,
ConnectionStatus,
ConnectionStatusEvent,
PairingV2Payload,
// Session
EternlSession,
AccountInfo,
ConnectionState,
// Events
SessionEventType,
SessionEventListener,
ConnectionChangeEvent,
WalletStatusEvent,
AccessGrantedEvent,
AccessDeniedEvent,
AccountsChangedEvent,
// Wallet device
WalletDeviceInfo,
WalletPlatform,
// Paired wallets
PairedWallet,
// CIP-30
NetworkType,
TransportType,
Paginate,
DataSignature,
CollateralParams,
} from '@eternl/hub-dapp-sdk';
// Value exports
import { Permission, EternlConnectError, ErrorCode } from '@eternl/hub-dapp-sdk';The previously documented
EternlConnectConfigandConnectionRequesttypes do not exist and are not exported.
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/
