Profile
Back to NewsBack
GitHub Trending 31 min
Reader Mode
unicity-sphere/sphere-sdk: The SDK for autonomous economic agents. Give an agent an identity, a wallet, and the ability to find, negotiate with, and settle with other agents - peer-to-peer, with perfect privacy and ultra-fast finality

unicity-sphere/sphere-sdk: The SDK for autonomous economic agents. Give an agent an identity, a wallet, and the ability to find, negotiate with, and settle with other agents - peer-to-peer, with perfect privacy and ultra-fast finality

7 hours ago

Sphere SDK

A modular TypeScript SDK for Unicity wallet operations (Unicity state transition network).

Features

  • Wallet Management - BIP39/BIP32 key derivation; optional password encryption of the stored seed (see Wallet Security & Encryption)
  • Payments - Engine-certified token transfers over the wallet-api vertical (durable server-side intents, mailbox delivery, crash-safe resume under the same transferId); server custody — the backend holds inventory, keys stay local
  • Payment Requests - Request payments over the wallet-api rail with encrypted memos and durable settling
  • Market (Intents) - Signed intent bulletin board with semantic search and live feed
  • Group Chat - NIP-29 relay-based group messaging with moderation
  • Messaging (Nostr) - NIP-17 DMs + NIP-29 group chat and nametag publishing — messaging only; not the payment rail
  • Multi-Address - HD address derivation (BIP32/BIP44)
  • Connect Protocol - dApp ↔ wallet communication via ConnectClient / ConnectHost (hosted wallet in an iframe, or WebSocket for Node.js dApps)

Installation

npm install @unicitylabs/sphere-sdk        # browser
npm install @unicitylabs/sphere-sdk ws     # Node.js: ws is required, see "Node.js Providers"

Quick Start Guides

Choose your platform:

| Platform | Guide | Required | Notes | |----------|-------|----------|-------| | Browser | QUICKSTART-BROWSER.md | SDK only | Default storage: IndexedDB. TypeScript: ./impl/browser ships no type declarations yet (see the shim) | | Node.js | QUICKSTART-NODEJS.md | SDK + ws, Node.js >= 22 | Default storage: a wallet file under ./sphere-data | | CLI | unicity-sphere/sphere-cli | Separate repository | Not published to npm yet | | dApp integration | CONNECT.md | SDK only | ws (Node.js dApps) |

CLI (Command Line Interface)

The Sphere CLI lives in its own repository, unicity-sphere/sphere-cli, and is not published to npm yet: npm install -g @unicity-sphere/cli fails with a 404. Its package.json depends on this SDK through a local path (file:../../sphere-sdk), so it builds only next to a checkout of this repository. See docs/QUICKSTART-CLI.md.

Quick Start

Setup is two provider layers, not one. createBrowserProviders / createNodeProviders
build only the base (storage + transport + oracle). You must then attach the wallet-api
transport config with createWalletApiProviders — money moves only through the wallet-api
vertical. Skipping it fails loudly: Sphere.init throws INVALID_CONFIG.
import { Sphere, TokenRegistry, getCoinIdBySymbol, randomUUID } from '@unicitylabs/sphere-sdk';
import { createBrowserProviders } from '@unicitylabs/sphere-sdk/impl/browser'; // untyped entry: add the declaration shim below
import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api';

// One network literal, used in all three places below. const NETWORK = 'testnet2';

// A per-device id: stable across launches on this device, different on every device. function deviceId(): string { let id = localStorage.getItem('sphere-device-id'); if (!id) { // The SDK's randomUUID(): unlike crypto.randomUUID(), it also works outside a secure context. id = randomUUID(); localStorage.setItem('sphere-device-id', id); } return id; }

// 1. Base providers: storage (IndexedDB) + transport (Nostr) + oracle (gateway). // network is required here: createBrowserProviders throws INVALID_CONFIG without it. const base = createBrowserProviders({ network: NETWORK, oracle: { apiKey: 'sk_ddc3cfcc001e4a28ac3fad7407f99590' }, // public testnet2 gateway key });

// 2. The wallet-api transport config that the payments vertical is composed from. // Returns { ...base, walletApi }; walletApi is a plain config object. const providers = createWalletApiProviders(base, { baseUrl: 'https://wallet-api.unicity.network', // testnet2 wallet-api network: NETWORK, deviceId: deviceId(), });

// 3. Load the wallet in this storage, or create one. network is required here too. const { sphere, created, generatedMnemonic } = await Sphere.init({ ...providers, network: NETWORK, autoGenerate: true, }); if (created && generatedMnemonic) { console.log('SAVE THIS RECOVERY PHRASE:', generatedMnemonic); }

// 4. Send: engine-driven, certified on-chain. The recipient needs a published identity // (chain pubkey), e.g. a registered Unicity ID; otherwise send fails with INVALID_RECIPIENT. // coinId is the 64-hex coin id; getCoinIdBySymbol() returns it for a symbol. await TokenRegistry.waitForReady(); // Sphere.init starts the registry load but does not await it const coinId = getCoinIdBySymbol('UCT'); // string | undefined if (!coinId) throw new Error('UCT is not in this network\'s token registry');

const result = await sphere.payments.send({ recipient: '@alice', amount: '1000000', // base units, as a decimal STRING (never a JS number) coinId, memo: 'hello', }); console.log(result.status); // 'delivered', or 'confirmed' with result.deliveryPending === true // A resolved send() means sent. deliveryPending === true is NORMAL, not a failure: the token is // certified on-chain and the mailbox delivery is retried automatically (see "Send result" below).

// 5. Receive: incoming transfers land automatically while the wallet runs (mailbox drain + // wake socket). To drain explicitly (e.g. a CLI/batch app), call receive(): const { transfers } = await sphere.payments.receive(); sphere.on('transfer:incoming', (t) => console.log('received from', t.senderNametag ?? t.senderPubkey));

console.log(await sphere.payments.assets());

generatedMnemonic is returned only by the Sphere.init call that created the wallet. The phrase is stored before the rest of the setup runs, so if that call then throws (for example, a requested nametag is already taken), the next Sphere.init loads the stored wallet with created: false. Gate your backup prompt on your own "backup confirmed" flag and read the phrase with sphere.getMnemonic() until the user confirms.

Nametag bindings do not carry a network yet, so the SDK cannot prove that a @nametag or DIRECT:// recipient uses your network. Every such send emits transfer:attention with code: 'recipient:network-unverified' and an empty transferId, and then proceeds on your network. Treat it as information, not an error; on mainnet, make sure the recipient runs mainnet. A bare 66-hex chain pubkey recipient is taken as being on your network.

What just happened (the provider model)

A wallet is composed from swappable ports, layered in two steps:

| Layer | Built by | What it supplies | |-------|----------|-------------------| | Base | createBrowserProviders / createNodeProviders | storage (keys/identity/journals), transport (Nostr — messaging/nametags only), oracle (gateway/trust base) | | wallet-api transport | createWalletApiProviders(base, …) | walletApi — the transport CONFIG ({ network, baseUrl, deviceId?, fetchFn?, webSocketFactory?, paymentsV2Transport? }) the payments vertical is composed from |

  • The rail is wallet-api, not Nostr. Transfers are certified on-chain by the token engine and the finished token is deposited into the recipient's wallet-api mailbox. Nostr carries messaging/nametags — it does not move payments.
  • Custody is server-side. The wallet-api backend holds your token inventory; your keys never leave the client. (Own-storage custody was rescinded — there is no local token store.)
  • The money ports are contract-enforced. StoragePort/DeliveryPort (modules/payments-v2/ports.ts) have wallet-api implementations; the paymentsV2Transport seam in the walletApi config lets tests/custom hosts inject a whole replacement bundle.
  • network placement. Required on createBrowserProviders/createNodeProviders, in the walletApi config, AND on Sphere.init; use one literal, 'testnet2', in all three places. Neither provider factory returns a network field, so ...providers cannot supply it. Sphere.init compares its own network with walletApi.network as plain strings and throws INVALID_CONFIG ("walletApi.network "testnet2" does not match the Sphere network ...") when they differ, including when Sphere.init gets no network at all; this happens before any storage write. 'testnet' and 'testnet2' reach the same endpoints but are different strings, so mixing them fails this check. The wallet-api deployment names its network too: the testnet2 deployment signs you in only as 'testnet2', and the SDK refuses a sign-in challenge for any other network. The base-provider literal is not compared by that check, but it scopes the storage keys, while the payments state is keyed by the Sphere.init network, so mixing the two literals splits one wallet's state across two names.
  • Messaging-only wallets say so out loud. A wallet that never touches money — a Nostr DM or group-chat bot — passes walletApi: 'none' instead of a config: no wallet-api session, device registration, mailbox drain, token engine or pv2g2: key. network is still required, because it selects the token registry and the group-chat relays. sphere.payments then throws PAYMENTS_NOT_COMPOSED and sphere.hasPayments is false. Omitting walletApi altogether still throws INVALID_CONFIG — a dropped env var must never read as a deliberate choice.
For manual/advanced provider wiring, see Custom Providers Configuration. For the deeper integration guide, see docs/INTEGRATION.md.

Send result (TransferResult)

send() resolves only when the payment is sent, with a TransferResult:

| Field | Meaning | |-------|---------| | status | 'delivered' when the payment landed in the recipient's mailbox, or 'confirmed' when the transfer is certified and delivery is still being retried (deliveryPending === true). send() never resolves with 'completed' or 'failed': a failure throws. ('submitted' and 'failed' appear only as transfer:updated event payloads.) | | deliveryPending | true when the spend is certified on-chain but the recipient's mailbox delivery was deferred (a full inbox / transient outage). This is success, not failure — the token is finalized and the finished blob is journaled and re-delivered automatically. | | deliveryState | 'landed' (delivered) or 'pending-delivery' (deferred, as above). |

A resolved send() is sent, whichever of the two statuses it carries. Use deliveryPending only to show a "delivery pending" hint — never as an error. A stale-but-spent source is self-healed (the next live coin is selected automatically).

Handling send() rejections: never re-send a possibly-committed payment (money-safety)

Some rejections mean the money may already have left the wallet. isPossiblyCommittedSendOutcome(err) is true for exactly these codes: SEND_SYNC_PENDING, CERTIFICATION_UNCONFIRMED, CHECKPOINT_PERSIST_FAILED, SPLIT_CHECKPOINT_LOST, CHECKPOINT_TRUSTBASE_MISMATCH and SEND_PARTIALLY_COMPLETED. Never call send() again for that payment: a new send() gets a new transfer id and pays the recipient a second time. The SDK finishes the original under its own transfer id; show it as pending (sphere.payments.pendingTransfers()), and wire any "retry" button to sphere.payments.resumeNow(). A PartialSendConflictError means part of the amount was delivered and is final; only err.remainingAmount is still owed. When isPossiblyCommittedSendOutcome(err) is false, the SDK's contract is that nothing left the wallet.

  • CERTIFICATION_UNCONFIRMED is a ProofUnconfirmedError (mayHaveCertified: true): the spend may already be on-chain but the proof fetch was inconclusive. SEND_SYNC_PENDING can mean the spend committed on-chain and the wallet-api mirror is still catching up.
  • Recovery is automatic. The open intent is replayed under the same transferId (recovers the proof + delivery, or records the spend if a rival tx won; never a second spend): partially-committed outcomes converge in-process, and every remaining open intent is resumed when the vertical starts (Sphere.init / Sphere.load / an address switch). sphere.payments.resumeNow() runs that convergence now; it is the only retry verb.
  • Clean failures you can branch on: SEND_INSUFFICIENT_BALANCE (when funds are pinned by transfers still converging, its message says how much and points to pendingTransfers()), INVALID_RECIPIENT, TRANSPORT_ERROR (the recipient lookup could not reach the relay) and VALIDATION_ERROR (a bad amount). INSUFFICIENT_BALANCE is never thrown.
  • Import the error helpers from the same entry point as Sphere, and read code structurally for the clean failures: errors thrown by provider code (the ./impl/* bundles, for example the Nostr transport during the recipient lookup) are a different SphereError class copy, so isSphereError() is false for them.
// Import the error helpers from the same entry point as Sphere (here: the package root).
import { PartialSendConflictError, isPossiblyCommittedSendOutcome } from '@unicitylabs/sphere-sdk';

try { const result = await sphere.payments.send({ recipient: '@alice', amount: '1000000', coinId }); // Resolved means sent: result.status is 'delivered', or 'confirmed' with deliveryPending === true. if (result.deliveryPending) show('Sent. Delivery to the recipient is pending and is retried automatically.'); } catch (err) { if (err instanceof PartialSendConflictError) { // Part of the amount was delivered and is final. Only err.remainingAmount is still owed: // if you pay it, do it as a NEW send of exactly that amount, never the original amount. show(Partly sent: ${err.remainingAmount} base units were not sent.); } else if (isPossiblyCommittedSendOutcome(err)) { // The money may already have left the wallet. Never call send() again for this payment: // the SDK completes it under the same transferId. Show it as pending. show('Sent, waiting for confirmation.'); const pending = await sphere.payments.pendingTransfers(); // rows for a "pending" list // A "retry" button calls sphere.payments.resumeNow(), never send(). } else { // Nothing left the wallet. Read code structurally: errors thrown by the providers // (e.g. the Nostr transport) are a different SphereError class copy, so isSphereError() is false for them. const code = (err as { code?: unknown } | null)?.code; switch (code) { case 'SEND_INSUFFICIENT_BALANCE': show((err as Error).message); break; // names pinned funds when transfers are converging case 'INVALID_RECIPIENT': show('Recipient not found'); break; case 'TRANSPORT_ERROR': show('Could not look up the recipient. Check the connection.'); break; default: show(err instanceof Error ? err.message : String(err)); } } }

TypeScript: declarations for ./impl/browser

@unicitylabs/sphere-sdk/impl/browser ships no type declarations in this release; under strict TypeScript add the declaration shim below (or a one-line declare module '@unicitylabs/sphere-sdk/impl/browser';, which types everything from that entry as any). Import createWalletApiProviders from the typed @unicitylabs/sphere-sdk/impl/shared/wallet-api subpath, as above.

// Consumer-side declarations for '@unicitylabs/sphere-sdk/impl/browser'.
// That entry ships no .d.ts (tsup builds it with dts: false), so strict
// TypeScript reports TS7016 on the import without this file. Delete it once
// the package ships declarations for ./impl/browser.
declare module '@unicitylabs/sphere-sdk/impl/browser' {
  import type {
    NetworkType, StorageProvider, TransportProvider, OracleProvider, PriceProvider,
    PricePlatform, GroupChatModuleConfig, MarketModuleConfig,
  } from '@unicitylabs/sphere-sdk';

export interface BrowserProvidersConfig { /* Required: createBrowserProviders throws INVALID_CONFIG without it. / network: NetworkType; debug?: boolean; storage?: { prefix?: string; dbName?: string; debug?: boolean }; transport?: { relays?: string[]; additionalRelays?: string[]; timeout?: number; autoReconnect?: boolean; debug?: boolean; reconnectDelay?: number; maxReconnectAttempts?: number; }; oracle?: { url?: string; apiKey?: string; timeout?: number; skipVerification?: boolean; debug?: boolean }; price?: { platform?: PricePlatform; apiKey?: string; baseUrl?: string; cacheTtlMs?: number; timeout?: number; debug?: boolean }; groupChat?: { enabled?: boolean; relays?: string[] } | boolean; market?: { apiUrl?: string; timeout?: number } | boolean; }

export interface BrowserProviders { storage: StorageProvider; transport: TransportProvider; oracle: OracleProvider; price?: PriceProvider; groupChat?: GroupChatModuleConfig | boolean; market?: MarketModuleConfig | boolean; }

export function createBrowserProviders(config: BrowserProvidersConfig): BrowserProviders; }

The shim declares only createBrowserProviders. The other ./impl/browser exports used later in this README (createLocalStorageProvider, createNostrTransportProvider, createUnicityAggregatorProvider) need their own declarations, or the one-line form.

Migrating off sphere.paymentsV2

The deprecated sphere.paymentsV2 alias and the paymentsV2: true init flag are removed in 0.15.0. sphere.payments is the only accessor, and it is the same facade the alias returned.

One behavioural difference matters: while no vertical is running (init in flight, mid address-switch, destroyed) the alias returned null and sphere.payments throws SphereError with code: 'NOT_INITIALIZED'. Call sites that leaned on the nullish alias — sphere.paymentsV2?.tokens(), ?? fallback, if (sphere.paymentsV2) as a readiness probe — silently degraded to "no payments" before and now throw, so catch NOT_INITIALIZED where you used to check for null. Code that runs after await Sphere.init(…) and before destroy() — everything else in this README — reads sphere.payments directly.

A wallet initialised with walletApi: 'none' has no payments at all: there sphere.payments throws PAYMENTS_NOT_COMPOSED, permanently, instead of the transient NOT_INITIALIZED. Use sphere.hasPayments to tell the two cases apart without a try/catch.

The accounting: / swap: options are not part of this cleanup: they still throw a typed INVALID_CONFIG, deliberately, because those modules were removed and a silently ignored option would hide that.

Network Configuration

The SDK ships network presets that configure all services automatically. network is required — there is no default:

| network literal | networkId | Gateway (preset) | Nostr relay (preset) | wallet-api baseUrl (you pass it) | |-------------------|-----------|------------------|----------------------|------------------------------------| | 'testnet2' | 4 | https://gateway.testnet2.unicity.network | wss://nostr-relay.testnet.unicity.network | https://wallet-api.unicity.network | | 'mainnet' | 1 | https://gateway.mainnet.unicity.network | the testnet relay (mainnet has none of its own yet) | https://wallet-api.mainnet.unicity.network | | 'testnet' | 4 | same as testnet2 | same as testnet2 | none: the testnet2 wallet-api signs in only as 'testnet2', and the SDK refuses its sign-in challenge for 'testnet'. Use 'testnet2' |

Live networks are testnet2 and mainnet, each with its own gateway and wallet-api deployment. testnet is a second key with testnet2's configuration (network id 4, taken from the trust base; the testnet2 token registry), but it is a different string, so it fails the network check against a 'testnet2' wallet-api config (see network placement). SPHERE_NETWORKS exposes only mainnet and testnet2. The v1 network is discontinued — the old goggregator-test testnet spoke the removed v1 protocol, and the dev network that aliased its trust base has been removed along with every other v1 pointer. On mainnet use network: 'mainnet' in createBrowserProviders/createNodeProviders, in the walletApi config and on Sphere.init, the mainnet wallet-api https://wallet-api.mainnet.unicity.network, and your mainnet gateway API key, which is a secret. Mainnet shares testnet2's Nostr relay for now, and its token registry lists no fungible coins yet. The transfer wire payload is the finished token blob — the base SDK's own Token.toCBOR() bytes, with no sphere envelope around them — deposited into the recipient's wallet-api mailbox.
> The network name (testnet2) and the base-SDK major (3.x since 0.15.0) are separate axes: testnet2 is still testnet2 after the 3.0.1 bump. What the bump changes is the bytes on that network — a gateway serving the v3 protocol accepts nothing a 2.x client writes, and vice versa.
// Use the testnet2 preset for all services
const presetOnly = createBrowserProviders({ network: 'testnet2' });

// Override specific services while using the network preset const customGateway = createBrowserProviders({ network: 'testnet2', oracle: { url: 'https://custom-gateway.example.com' }, // custom testnet2 gateway });

API Key

The SDK bundles no default API key. Pass the gateway key via oracle: { apiKey }. Without one the token engine is still built, the SDK logs a TokenEngine warning, and gateway requests are unauthenticated; whether a gateway serves them is the gateway's policy.

const withApiKey = createBrowserProviders({
  network: 'testnet2',
  oracle: { apiKey: 'sk_...' },
});

The testnet2 key is not a secret — it is published in .env.example and safe to keep in docs and client code. A mainnet key, by contrast, IS a secret: keep it in your deploy environment only.

Testnet2 endpoints (the values we build with)

The testnet2 preset wires most of these automatically — you only pass network, oracle.apiKey, and the wallet-api baseUrl. The full set, for reference and manual wiring:

| What | Value | |------|-------| | Network | testnet2, networkId 4 (the testnet key has the same gateway, relays and token registry, but it is a different literal and the testnet2 wallet-api signs in only as 'testnet2'; use testnet2) | | Aggregator / gateway (token engine) | https://gateway.testnet2.unicity.network | | Aggregator API key (public — not a secret) | sk_ddc3cfcc001e4a28ac3fad7407f99590 | | wallet-api (delivery + token storage) | https://wallet-api.unicity.network | | Nostr relay (messaging / nametags) | wss://nostr-relay.testnet.unicity.network | | Group-chat relay (NIP-29) | wss://sphere-relay.unicity.network | | Token registry | https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet2.json |

The aggregator key above is the testnet2 key only and is safe in client code; a mainnet key is a real secret and must never be committed.

Mainnet (network: 'mainnet', networkId 1): gateway https://gateway.mainnet.unicity.network, wallet-api https://wallet-api.mainnet.unicity.network, the same Nostr and group-chat relays as testnet2, and token registry https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.mainnet.json, which currently lists only the non-fungible base token type (no fungible coins yet).

Price Provider (Optional)

Enable fiat price display by adding a price config. Currently supports CoinGecko API (free and pro tiers).

// With CoinGecko (free tier, no API key)
const base = createBrowserProviders({
  network: 'testnet2',
  price: { platform: 'coingecko' },
});
// With CoinGecko Pro: price: { platform: 'coingecko', apiKey: 'CG-xxx' }

const providers = createWalletApiProviders(base, { baseUrl: 'https://wallet-api.unicity.network', network: 'testnet2', }); const { sphere } = await Sphere.init({ ...providers, network: 'testnet2', autoGenerate: true });

// Assets with price data const assets = await sphere.payments.assets(); // [{ coinId, symbol, totalAmount, priceUsd: 97500, fiatValueUsd: 975.00, change24h: 2.3, ... }]

// Total portfolio value in USD const totalUsd = assets.reduce((sum, a) => sum + (a.fiatValueUsd ?? 0), 0);

Without price config, the price fields in assets() are null. All other functionality works normally.

You can also set the price provider after initialization — price is a composition-time property of the payments vertical, so verticals composed after the call (the next address switch) pick it up:

import { createPriceProvider } from '@unicitylabs/sphere-sdk';

sphere.setPriceProvider(createPriceProvider({ platform: 'coingecko', apiKey: 'CG-xxx', }));

Test Tokens on Testnet (Self-Mint)

There is no faucet. On testnet you top up your wallet by self-minting fungible tokens via the token engine — mint(coinIdHex, amount) mints a finished token directly to this wallet (journal-first: crash-safe, a replay converges idempotently):

import { TokenRegistry, getCoinIdBySymbol } from '@unicitylabs/sphere-sdk';

// Resolve the coin's hex id from the token registry (or pass a hex coinId directly). await TokenRegistry.waitForReady(); // Sphere.init starts the registry load but does not await it const coinId = getCoinIdBySymbol('UCT'); // string | undefined if (!coinId) throw new Error('UCT is not in this network\'s token registry');

const result = await sphere.payments.mint(coinId, 1000n); if (result.success) { console.log('Minted token:', result.tokenId); } else { console.error('Mint failed:', result.error); }

Note: Minting needs the token engine, which Sphere.init builds from the oracle's trust base and
gateway URL (without them Sphere.init rejects with INVALID_CONFIG); pass the gateway key via
oracle: { apiKey }. A mint that fails after it was journaled resolves { success: false, error }
and is replayed by the SDK: do not call mint() again for it. See API Key above.

Multi-Address Support

The SDK supports HD (Hierarchical Deterministic) wallets with multiple addresses:

// Get current address index
const currentIndex = sphere.getCurrentAddressIndex(); // 0

// Switch to a different address await sphere.switchToAddress(1); console.log(sphere.identity?.directAddress); // DIRECT://... (address at index 1)

// Register nametag for this address (independent per address) await sphere.registerNametag('bob');

// Switch back to first address await sphere.switchToAddress(0);

// Get the nametag of a specific address. Index 1 is tracked because we switched to it. const bobNametag = sphere.getTrackedAddress(1)?.nametag; // 'bob' // getNametagForAddress takes the short addressId ('DIRECT_xxxxxx_yyyyyy'), not an index: const sameNametag = sphere.getNametagForAddress(sphere.getTrackedAddress(1)?.addressId);

// All active addresses with their nametags (TrackedAddress[], sorted by index) const active = sphere.getActiveAddresses(); // [{ index: 0, addressId: 'DIRECT_…', directAddress: 'DIRECT://…', nametag: 'alice', … }, { index: 1, …, nametag: 'bob' }]

// deriveAddress() returns keys, not an address: { privateKey, publicKey, path, index }. const keys2 = sphere.deriveAddress(2); console.log(keys2.publicKey, keys2.path); // never log or serialise the whole object: it holds the private key

deriveAddress(index) returns key material, { privateKey, publicKey, path, index }, not an address; never log or serialise the whole object. For the DIRECT:// address use sphere.identity?.directAddress (active address) or sphere.getTrackedAddress(index)?.directAddress (after switchToAddress(index)). getAllAddressNametags() is deprecated; it returns Map>, keyed by the short addressId.

Identity Properties

Important: The DIRECT address is the primary address for the Unicity network.

interface Identity {
  chainPubkey: string;         // 33-byte compressed secp256k1 public key
  directAddress?: string;      // DIRECT address (DIRECT://...) - PRIMARY ADDRESS
  ipnsName?: string;           // legacy derived id ('12D3KooW…'); nothing in the SDK uses it
  nametag?: string;            // Registered nametag (@username)
}

// Access identity - use directAddress as primary console.log(sphere.identity?.directAddress); // DIRECT://0000be36... (PRIMARY) console.log(sphere.identity?.nametag); // alice (human-readable) console.log(sphere.identity?.chainPubkey); // 02abc123... (33-byte compressed)

Address Change Event

Event handlers receive the payload directly: sphere.on('identity:changed', (e) => e.addressIndex), not e.data.addressIndex. on() returns an unsubscribe function.

// Listen for address switches
const off = sphere.on('identity:changed', (event) => {
  console.log('Switched to address index:', event.addressIndex);
  console.log('L3 address:', event.directAddress);
  console.log('Chain pubkey:', event.chainPubkey);
  console.log('Nametag:', event.nametag);
});

// Nametag recoveries after init (e.g. after switchToAddress) sphere.on('nametag:recovered', (event) => { console.log('Recovered nametag from Nostr:', event.nametag); });

off(); // stop listening

Nametag recovery during Sphere.init / load / import finishes, and emits nametag:recovered, before the call returns, so a listener added afterwards does not see it. Check sphere.identity?.nametag after init. The event is useful for later recoveries, such as after switchToAddress().

Payment Requests

Request payments from others over the wallet-api rail (sphere.payments.requests). Request memos ride an encrypted recipient-ECDH envelope.

  • requests.create(to, { coinId, amount, memo? }) never throws; it resolves { success, requestId?, error? }. Check success. coinId is the 64-hex coin id (look it up with getCoinIdBySymbol()).
  • Never pay from inside the payment_request:incoming handler without the user's decision; pay() and decline() are alternatives. pay() rethrows send()'s errors: handle them as in Handling send() rejections.
  • payment_request:updated reports requests you received. The SDK does not track requests you created: detect payment through transfer:incoming or sphere.payments.history().
  • request.amount is a base-unit string and request.coinId the hex id; request.symbol is not set by the SDK event.
When the send inside pay() fails with a possibly-committed error, pay() links the request to that transfer (the error's transferId) in the payments journal and marks it 'settling' before it rethrows, so the request is not payable, and the link survives a restart. One exception: if writing that link to storage fails, pay() rejects with the storage error instead of the send error, so isPossiblyCommittedSendOutcome is false for it although the payment may have gone out; the link is then held in memory and reaches storage only with a later successful journal write. A second pay() of the same id while the first is still running joins it. The link is written after the send returns or throws, not before it starts. If the app or process stops while pay() is still waiting on the send, or before a link that failed to write reaches storage, no link exists: on the next start the request is listed as 'pending' again and payment_request:incoming fires again, even if the transfer went through (a transfer the SDK had already recorded is resumed when the wallet starts). Before paying a request again after a restart, check sphere.payments.pendingTransfers() and sphere.payments.history() for a transfer to that requester.
import { TokenRegistry, getCoinIdBySymbol } from '@unicitylabs/sphere-sdk';

// Requester side: create() never throws. It resolves { success, requestId?, error? }. await TokenRegistry.waitForReady(); const coinId = getCoinIdBySymbol('UCT'); // the 64-hex coin id, or undefined if (coinId) { const created = await sphere.payments.requests.create('@bob', { coinId, amount: '1000000', memo: 'Payment for order #1234', }); if (!created.success) console.error(created.error); }

// Payer side: never pay from the event handler itself. Show the request and let the user decide. sphere.on('payment_request:incoming', async (request) => { // request.amount is a base-unit string and request.coinId the hex coin id; request.symbol is not set here. console.log(${request.senderNametag ?? request.senderPubkey} requests ${request.amount} of ${request.coinId}); try { if (await askUser(request)) { await sphere.payments.requests.pay(request.id); } else { // A server 403/409 propagates: a refused decline is not success. await sphere.payments.requests.decline(request.id); } } catch (err) { console.error('payment request failed', err); // pay() rethrows send() errors: handle them like send() } });

// Current views (requests you received) + housekeeping const open = sphere.payments.requests.list(); sphere.payments.requests.dismissProcessed();

Group Chat (NIP-29)

Relay-based group messaging using the NIP-29 protocol. The module embeds its own Nostr connection separate from the wallet transport.

Enabling Group Chat

// Enable with network defaults (wss://sphere-relay.unicity.network)
const { sphere } = await Sphere.init({
  ...providers,
  network: 'testnet2', // also selects the default group-chat relay
  autoGenerate: true,
  groupChat: true,
});
// Or enable with a custom relay: groupChat: { relays: ['wss://my-nip29-relay.com'] }

// Access the module (null unless Sphere.init got groupChat) const gc = sphere.groupChat!;

Connection

// Connect to the NIP-29 relay
await gc.connect();
console.log('Connected:', gc.getConnectionStatus());

// Check if current user is a relay admin const isRelayAdmin = await gc.isCurrentUserRelayAdmin();

Groups

import { GroupVisibility } from '@unicitylabs/sphere-sdk';

// Create a public group. createGroup resolves GroupData | null. const group = await gc.createGroup({ name: 'General', description: 'Public discussion', }); if (!group) throw new Error('Could not create the group');

// Create a private group const privateGroup = await gc.createGroup({ name: 'Team', visibility: GroupVisibility.PRIVATE, });

// Create a write-restricted group (only admins and moderators can post) const announcements = await gc.createGroup({ name: 'Announcements', writeRestricted: true, });

// Discover and join const available = await gc.fetchAvailableGroups(); // public groups on relay await gc.joinGroup(group.id);

// Join private group with invite if (privateGroup) await gc.joinGroup(privateGroup.id, inviteCode);

// List joined groups const groups = gc.getGroups();

// Leave or delete await gc.leaveGroup(group.id); await gc.deleteGroup(group.id); // admin only

Messaging

// Send a message. sendMessage resolves GroupMessageData | null.
const msg = await gc.sendMessage(group.id, 'Hello!');

// Reply to a message: the third argument is replyToId?: string await gc.sendMessage(group.id, 'Agreed', msg?.id);

// Fetch messages from the relay: fetchMessages(groupId, since?: number (ms), limit?: number) const messages = await gc.fetchMessages(group.id, undefined, 50);

// Get locally cached messages const cached = gc.getMessages(group.id);

// Listen for new messages in real-time const unsubscribe = gc.onMessage((message) => { console.log([${message.groupId}] ${message.senderPubkey}: ${message.content}); });

Members & Moderation

// Get members
const members = gc.getMembers(group.id);

// Check roles gc.isCurrentUserAdmin(group.id); // boolean gc.isCurrentUserModerator(group.id); // boolean await gc.canModerateGroup(group.id); // includes relay admin check gc.canWriteToGroup(group.id); // false if write-restricted and not admin/moderator

// Moderate (requires admin/moderator role) await gc.kickUser(group.id, userPubkey, 'reason'); await gc.deleteMessage(group.id, messageId);

Invites (Private Groups)

// Create invite code (admin only). createInvite resolves string | null.
const invite = await gc.createInvite(group.id);

// Share invite code, recipient joins with: if (invite) await gc.joinGroup(group.id, invite);

Unread Counts

const total = gc.getTotalUnreadCount();
gc.markGroupAsRead(group.id);

Key Types

interface GroupData {
  id: string;
  relayUrl: string;
  name: string;
  description?: string;
  picture?: string;
  visibility: GroupVisibility;  // 'PUBLIC' | 'PRIVATE'
  createdAt: number;
  updatedAt?: number;
  memberCount?: number;
  unreadCount?: number;
  lastMessageTime?: number;
  lastMessageText?: string;
  writeRestricted?: boolean;   // Only admins and moderators can post
  localJoinedAt?: number;      // When the current user joined this group locally
}

interface GroupMessageData { id?: string; groupId: string; content: string; timestamp: number; senderPubkey: string; senderNametag?: string; replyToId?: string; previousIds?: string[]; }

interface GroupMemberData { pubkey: string; groupId: string; role: GroupRole; // 'ADMIN' | 'MODERATOR' | 'MEMBER' nametag?: string; joinedAt: number; }

Direct Messages (NIP-17)

End-to-end encrypted DMs via NIP-17 gift wrap, accessed through sphere.communications:

// Send a DM (by nametag or pubkey)
await sphere.communications.sendDM('@alice', 'Hello!');

// Listen for incoming DMs sphere.communications.onDirectMessage((msg) => { console.log(From ${msg.senderNametag ?? msg.senderPubkey}: ${msg.content}); });

DM History on Connect

By default, the SDK resumes from the last processed DM timestamp (persisted in storage). On first connect, it starts from "now" — no historical replay.

Sphere.init also accepts a dmSince option (unix seconds), meant as a fallback start for that first subscription. With the Nostr transport it does not take effect in this release: Sphere.init records it only after the wallet's DM subscription is already open, so a first connect starts from "now" either way.

Ephemeral Mode (No Caching)

For anonymous agents or LLM bots that don't need message history, disable DM caching. A bot that never moves money also passes walletApi: 'none' (see the provider model):

const { sphere } = await Sphere.init({
  ...base,             // the createBrowserProviders / createNodeProviders result, without createWalletApiProviders
  walletApi: 'none',   // messaging only: no wallet-api session, no token engine, no money
  network: 'testnet2', // still required: it selects the token registry and group-chat relays
  autoGenerate: true,
  communications: { cacheMessages: false },
});

// Stream-only: receive, process, forget sphere.communications.onDirectMessage((msg) => { processAndReply(msg); });

// sendDM still works — message is sent but not stored locally await sphere.communications.sendDM('@alice', 'response');

When cacheMessages is false:

  • onDirectMessage() handlers and message:dm events fire normally
  • Messages are never stored in memory or persisted to storage
  • getConversation() / getConversations() return empty results
  • Deduplication is skipped (duplicate relay deliveries may trigger duplicate events)

Alternative: Manual Create/Load

import { Sphere } from '@unicitylabs/sphere-sdk';
import {
  createLocalStorageProvider,
  createNostrTransportProvider,
  createUnicityAggregatorProvider,
} from '@unicitylabs/sphere-sdk/impl/browser'; // untyped entry: declare these too (see the TypeScript note above)

const NETWORK = 'testnet2';

const storage = createLocalStorageProvider({ network: NETWORK }); // Without relays the transport falls back to public Nostr relays (relay.damus.io, nos.lol, // relay.nostr.band), so pass the Unicity relay explicitly. const transport = createNostrTransportProvider({ relays: ['wss://nostr-relay.testnet.unicity.network'], }); // network is required: it selects the trust base the token engine is built from. A trustBaseUrl // is only an override and still needs network as its fallback. The apiKey authenticates gateway requests. const oracle = createUnicityAggregatorProvider({ url: 'https://gateway.testnet2.unicity.network', apiKey: 'sk_...', network: NETWORK, });

// The wallet-api transport config — REQUIRED for money (init throws INVALID_CONFIG without it) const walletApi = { network: NETWORK, baseUrl: 'https://wallet-api.unicity.network', deviceId: 'device-1234', // stable on this device, different on every device (e.g. a persisted UUID) };

// Check if wallet exists if (await Sphere.exists(storage)) { // Load existing wallet: Sphere.load resolves the instance itself const sphere = await Sphere.load({ storage, transport, oracle, walletApi, network: NETWORK }); } else { // Create new wallet with mnemonic const mnemonic = Sphere.generateMnemonic(); const sphere = await Sphere.create({ mnemonic, storage, transport, oracle, walletApi, network: NETWORK, }); console.log('Save this mnemonic:', mnemonic); }

Import from Master Key (Legacy Wallets)

For wallets whose master key was extracted elsewhere (e.g. an older backup), into a storage that holds no wallet yet — over one that already holds a wallet Sphere.import refuses with ALREADY_INITIALIZED unless you pass overwrite: true (see the note below):

// Import from master key + chain code (BIP32 mode) into a storage with no wallet yet.
// Sphere.import resolves the instance itself.
const bip32Wallet = await Sphere.import({
  masterKey: '64-hex-chars-master-private-key',
  chainCode: '64-hex-chars-chain-code',
  basePath: "m/84'/1'/0'",  // BIP84 account path
  derivationMode: 'bip32',
  storage, transport, oracle, walletApi,
  network: 'testnet2',
});

// Or, instead of the call above, import from master key only (WIF HMAC mode) const wifWallet = await Sphere.import({ masterKey: '64-hex-chars-master-private-key', derivationMode: 'wif_hmac', storage, transport, oracle, walletApi, network: 'testnet2', });

Sphere.import() replaces a wallet only when you pass overwrite: true. When a wallet already
exists on the given storage — or a Sphere is live on that storage object — import rejects with
ALREADY_INITIALIZED and leaves the wallet as it was. It checks every input first — network,
password, the mnemonic, the master key and the chain code — so an import rejected for any of those
erases nothing either. Into a storage with no wallet no flag is needed.
> With overwrite: true the wipe destroys live Spheres. Import then calls Sphere.clear() before
writing, which calls destroy() on every live Sphere built on that backing store: their
payments verticals stop, their providers disconnect, and every sphere.on() handler goes with
them. The scope is the store, not the provider object: two provider objects reporting the same
backingStoreId share the teardown, while a Sphere on unrelated storage is left alone. Drop
your references to the old instance rather than reusing it.
> The clear also erases the storage's payments state (pv2g2:*), including the journals of
transfers still in flight, so do not overwrite while transfers are pending. It runs before the new
wallet is brought up, so a failure after the input checks (a storage reconnect, provider start-up, a
taken nametag) does not bring the old wallet back: back up its recovery phrase before you
overwrite. With IndexedDB the store is the whole database named by dbName: every key
prefix in it is erased, so give each wallet its own dbName. On Node, keep wallets side by side
with one walletFileName each (see Node.js Providers).

Wallet Export/Import (JSON)

// Export to JSON (for backup)
const json = sphere.exportToJSON();
console.log(JSON.stringify(json));

// Export with encryption const encryptedJson = sphere.exportToJSON({ password: 'user-password' });

// Export with multiple addresses const multiJson = sphere.exportToJSON({ addressCount: 5 });

// Import from JSON. importFromJSON never throws: { success, sphere?, mnemonic?, error? }. // Like Sphere.import it needs walletApi and network. Over a storage that already holds a wallet it // returns { success: false, error } unless overwrite: true is passed, which clears that wallet first. const res = await Sphere.importFromJSON({ ...providers, // storage, transport, oracle, walletApi network: 'testnet2', overwrite: true, // this storage holds the wallet exported above: replace it jsonContent: JSON.stringify(encryptedJson), password: 'user-password', // decrypts an encrypted backup }); if (!res.success || !res.sphere) throw new Error(res.error ?? 'import failed'); const restored = res.sphere; // keep it: this is the live instance

importFromJSON (and importFromLegacyFile) use password only to decrypt the backup: the imported seed is stored without a password (see Wallet Security & Encryption).

Wallet Info & Backup

// Get wallet info
const info = sphere.getWalletInfo();
console.log('Source:', info.source);        // 'mnemonic' | 'file' | 'unknown'
console.log('Has mnemonic:', info.hasMnemonic);
console.log('Derivation mode:', info.derivationMode);
console.log('Base path:', info.basePath);

// Get mnemonic for backup (if available) const mnemonic = sphere.getMnemonic(); if (mnemonic) { console.log('Backup this:', mnemonic); }

Core Utilities

The SDK exports commonly needed utility functions:

import {
  // Crypto
  bytesToHex, hexToBytes,
  generateMnemonic, validateMnemonic,
  sha256,
  getPublicKey, createKeyPair,

// Currency conversion parseTokenAmount, // "1.5" → 1500000000000000000n (strict; throws on invalid input) safeParseTokenAmount, // like parseTokenAmount but returns null instead of throwing toHumanReadable, // 1500000000000000000n → "1.5" formatAmount, // Format with decimals and symbol

// Base58 (Bitcoin-style) base58Encode, base58Decode, isValidPrivateKey,

// General utilities sleep, randomHex, randomUUID, findPattern, extractFromText, } from '@unicitylabs/sphere-sdk';

Token format & verification

Tokens are opaque CBOR blobs — the base SDK's own Token.toCBOR() bytes, with no sphere-private envelope wrapped around them (Token.sdkData carries the hex when a blob is loaded). That is the same form on the wire, in the wallet-api mailbox and in server storage. Since 0.15.0 those bytes are state-transition-sdk 3.x CBOR; a 2.x blob does not decode and is rejected on receipt (the drain warns and acks it as invalid rather than silently dropping it).

Inventory lives in the wallet-api backend; the SDK downloads blobs on demand (lazy tokens carry value metadata only until selected for a spend). Every incoming token is engine-verified (full trust-base proof check) and ownership-checked before it enters the balance — there is no separate validate step to run.

Architecture

Single Identity Model: A single secp256k1 key pair backs the L3 identity. One mnemonic = one wallet.

mnemonic → master key → BIP32 derivation → identity
                                              ↓
                        ┌─────────────────────┴─────────────────────┐
                        │              shared keys                  │
                        │  privateKey:   "abc..."  (hex secp256k1)  │
                        │  chainPubkey:  "02def..." (33-byte comp.) │
                        │  directAddress: "DIRECT://..." (L3)       │
                        └─────────────────────┬─────────────────────┘
                                              ↓
              ┌──────────────────┬──────────────────┐
              ↓                  ↓                  ↓
         L3 (Unicity)        Group Chat           Nostr
       sphere.payments    sphere.groupChat  sphere.communications
       Tokens, engine     NIP-29 messaging    P2P messaging

``` Sphere (main entry point) ├── identity - Wallet identity (address, publicKey, nametag) ├── payments - The payments facade: assets/tokens/history/send/mint/receive/requests ├── market - Intent bulletin board (via sphere.market) ├── groupChat - NIP-29 group messaging (via sphere.groupChat) └── communications - Direct messages & broadcasts

Payments vertical (modules/payments-v2/ —

... (README truncated for length)

Chat with me