Skip to content

Sphere SDK Integration Guide

Quick Start: For a fast setup, see the platform-specific guides: - Browser Quick Start - Web applications - Node.js Quick Start - Server-side / CLI - Connect Protocol - Wallet ↔ dApp communication

This document covers advanced integration patterns, the v2 wallet composition model, custom provider implementations, and production custody patterns.

Table of Contents

  1. Setup
  2. Wallet Composition (v2)
  3. Custody Models
  4. Wallet Operations
  5. L3 Payments
  6. Payment Requests
  7. Communications
  8. Invoicing / Accounting
  9. Token Sync (IPFS Optional Backup)
  10. Custom Providers
  11. Events
  12. Error Handling
  13. Testing

Setup

Step 1: Create Base Providers

The first step is to create a base provider bundle with storage, transport, and oracle configuration:

// Browser (requires CORS proxy for free CoinGecko API — see "CORS Proxy" section below)
import { createBrowserProviders } from '@unicitylabs/sphere-sdk/impl/browser';

const baseProviders = createBrowserProviders({
  network: 'testnet',  // = testnet2: the v2 gateway network (gateway.testnet2.unicity.network)
  oracle: {
    apiKey: 'sk_ddc3cfcc001e4a28ac3fad7407f99590',  // testnet2 public key (NOT secret)
  },
  price: {
    platform: 'coingecko',
    baseUrl: '/api/coingecko',  // CORS proxy path (see "CORS Proxy" section)
  },
});
// Node.js (no proxy needed)
import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs';

const baseProviders = createNodeProviders({
  network: 'testnet',  // = testnet2 (alias 'testnet2' also accepted)
  dataDir: './wallet-data',
  tokensDir: './tokens-data',
  oracle: {
    apiKey: 'sk_ddc3cfcc001e4a28ac3fad7407f99590',  // testnet2 public key
  },
  price: { platform: 'coingecko', apiKey: 'CG-xxx' },  // Optional
});

Networks (Post v1-Cutover)

  • testnet / testnet2: v2 state-transition gateway network. testnet is an alias for testnet2. Both resolve to gateway.testnet2.unicity.network.
  • mainnet / dev: Still point at v1-era aggregators. The v2 token engine cannot operate against them and will fail loudly with AGGREGATOR_ERROR until these gateways are cut over.

Aggregator API Key

The SDK does not ship a default aggregator API key. Pass it explicitly via oracle.apiKey when creating providers:

  • testnet / testnet2 keys are NOT secret — safe to commit in .env.example and show in docs.
  • mainnet keys ARE secret — keep them only in your deploy environment, never committed.

If no apiKey is provided, the v2 token engine still constructs, but its gateway requests are unauthenticated and the SDK logs a TokenEngine warning. On testnet2, an explicit key is required for send() and mint() operations.


Wallet Composition (v2)

⚠️ CRITICAL SETUP GAP

If you stop at Step 1 (base providers only) and pass them directly to Sphere.init(), you will have a legacy wallet that cannot perform v2 mailbox delivery.

The v2 token engine requires a DeliveryProvider (for certified token transfer to recipient mailbox) and a TokenStorageProvider (for recipient token tracking). Without composition, the wallet can still construct, but send() calls will fail with missing delivery infrastructure or fall back to v1 behavior.

You must complete Step 2: compose the base providers with v2 wallet-api infrastructure.

Step 2: Compose v2 Wallet-Api Providers

Add the critical composition layer that enables v2 token delivery and storage:

import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api';

const providers = createWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',  // Canonical wallet-api host for testnet2
  network: 'testnet2',
  deviceId: 'my-stable-device-id',  // Stable identifier for this device (e.g., UUID or hostname)
});

// providers now includes:
// - all baseProviders (storage, transport, oracle)
// - delivery: WalletApiMailboxProvider (certified token delivery via REST mailbox)
// - tokenStorage: WalletApiTokenStorageProvider (recipient token archival)
// - walletApi: configured client for mailbox upload + token retrieval

WalletApiCompositionConfig:

Field Type Required Description
baseUrl string Yes Base URL of the wallet-api instance (e.g., https://wallet-api.unicity.network for testnet2)
network string Yes Network identifier; must match the base providers' network (testnet2, testnet, etc.)
deviceId string No Stable device identifier for this client. If omitted, a random UUID is generated. Used to de-duplicate mailbox entries on reconnect.

Step 3: Alternative Composition (Bring Your Own Storage)

If you want to manage token storage yourself instead of using the wallet-api storage backend:

import { createOwnStorageWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api';

const providers = createOwnStorageWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',
  network: 'testnet2',
  deviceId: 'my-stable-device-id',
  tokenStorage: myCustomTokenStorageProvider,  // You provide the TokenStorageProvider
});

This variant uses your own TokenStorageProvider (e.g., custom database, filesystem) while still using wallet-api for delivery.

Step 4: Initialize the Wallet

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

// Pass the composed providers (which include delivery + tokenStorage)
const { sphere, created, generatedMnemonic } = await Sphere.init({
  ...providers,  // includes storage, transport, oracle, delivery, tokenStorage, walletApi
  autoGenerate: true,  // Generate mnemonic if no wallet exists
  nametag: 'alice',    // Optional: register @alice nametag
  password: 'secret',  // Optional: encrypt mnemonic (PBKDF2; plaintext if omitted)
});

if (created && generatedMnemonic) {
  // First launch — show mnemonic to user for backup
  console.log('Save this mnemonic:', generatedMnemonic);
}

// Wallet is now ready with v2 delivery + mailbox support
console.log('Address:', sphere.identity?.directAddress);  // DIRECT://... (L3)

Complete Node.js Example

import { Sphere } from '@unicitylabs/sphere-sdk';
import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs';
import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api';

// Step 1: Base providers
const baseProviders = createNodeProviders({
  network: 'testnet',
  dataDir: './wallet-data',
  tokensDir: './tokens-data',
  oracle: { apiKey: 'sk_ddc3cfcc001e4a28ac3fad7407f99590' },
});

// Step 2: Compose with wallet-api
const providers = createWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',
  network: 'testnet2',
  deviceId: 'my-stable-device-id',
});

// Step 3: Initialize wallet
const { sphere, created, generatedMnemonic } = await Sphere.init({
  ...providers,
  autoGenerate: true,
});

// Step 4: Use v2 payments (sender-driven, certified on-chain, delivered via mailbox)
const result = await sphere.payments.send({
  recipient: '@alice',
  amount: '1000000',
  coinId: 'UCT',
  memo: 'hi',
});

console.log('Status:', result.status);  // 'pending' | 'submitted' | 'confirmed' | 'delivered' | 'completed' | 'failed'
console.log('Delivery pending:', result.deliveryPending);  // true = certified on-chain, awaiting recipient mailbox delivery (NORMAL)

// Receive tokens
const { transfers } = await sphere.payments.receive(undefined, (t) => {
  console.log('received', t.amount, t.coinId);
});

Custody Models

Sphere v2 supports two custody patterns for token storage:

External Custody (Wallet-Api Hosted)

Default: createWalletApiProviders(baseProviders, config)

  • Storage: Tokens are stored in the wallet-api's encrypted token archive (per-wallet, per-recipient).
  • Delivery: Certified tokens are delivered via wallet-api REST mailbox.
  • Custody: The wallet-api provider holds encrypted token blobs; recipient controls decryption keys.
  • Use case: Multi-device wallets, cloud-synced recipients, scaling to many concurrent devices.
  • Trade-off: Requires trust in wallet-api availability + encryption at rest.
const providers = createWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',
  network: 'testnet2',
  deviceId: 'device-1',
});

Inventory Custody (Bring Your Own Storage)

Alternative: createOwnStorageWalletApiProviders(baseProviders, config)

  • Storage: Tokens are stored in your own TokenStorageProvider (filesystem, database, etc.).
  • Delivery: Certified tokens still arrive via wallet-api mailbox; you store them locally.
  • Custody: You retain full control of token data; no third-party archive.
  • Use case: Single-device wallets, full local sovereignty, offline-capable clients.
  • Trade-off: Your application must persist + backup tokens; multi-device sync is manual.
import { FileStorageProvider } from '@unicitylabs/sphere-sdk/impl/nodejs';

const customTokenStorage = new FileStorageProvider('./tokens');

const providers = createOwnStorageWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',
  network: 'testnet2',
  deviceId: 'my-device',
  tokenStorage: customTokenStorage,
});

Wallet Operations

Check if Wallet Exists

const exists = await Sphere.exists(providers.storage);
// Sphere.init() handles both creation and loading automatically
const { sphere, created, generatedMnemonic } = await Sphere.init({
  ...providers,
  autoGenerate: true,  // Generate mnemonic if wallet doesn't exist
  nametag: 'alice',    // Optional: register nametag
});

if (created && generatedMnemonic) {
  console.log('Backup these words:', generatedMnemonic);
}

Import from Mnemonic

const { sphere } = await Sphere.init({
  ...providers,
  mnemonic: 'abandon abandon abandon ...',
});

Get Identity

const identity = sphere.identity;

console.log('Chain Pubkey:', identity.chainPubkey);   // 33-byte compressed secp256k1
console.log('Direct Address:', identity.directAddress); // DIRECT://... (L3)
console.log('Nametag:', identity.nametag);            // e.g., 'alice'

Clear Wallet

await Sphere.clear({ storage: providers.storage, tokenStorage: providers.tokenStorage });

Multi-Address Derivation

SDK2 supports HD (Hierarchical Deterministic) address derivation following BIP32/BIP44 standards.

// Derive additional receiving addresses
const addr1 = sphere.deriveAddress(1);  // m/44'/0'/0'/0/1
const addr2 = sphere.deriveAddress(2);  // m/44'/0'/0'/0/2

console.log('Address 1:', addr1.address);
console.log('Address 2:', addr2.address);

// Derive change addresses
const change0 = sphere.deriveAddress(0, true);  // m/44'/0'/0'/1/0

// Derive at arbitrary path
const custom = sphere.deriveAddressAtPath("m/44'/0'/0'/0/10");

// Get multiple addresses at once
const addresses = sphere.deriveAddresses(5);  // First 5 receiving addresses
const allAddrs = sphere.deriveAddresses(5, true);  // 5 receiving + 5 change

// Check derivation capability
if (sphere.hasMasterKey()) {
  console.log('HD derivation available');
  console.log('Base path:', sphere.getBasePath());
}

Each derived address has its own keypair but shares the same master seed:

interface AddressInfo {
  privateKey: string;  // Unique per address
  publicKey: string;   // Unique per address
  path: string;        // Full BIP32 path
  index: number;       // Address index
}

Tracked Addresses

The SDK tracks which addresses have been activated (via create, switchToAddress, registerNametag). This lets UI display the list of used addresses with metadata.

// Get all active (non-hidden) addresses
const addresses = sphere.getActiveAddresses();
for (const addr of addresses) {
  console.log(`#${addr.index}: ${addr.directAddress}`);
  console.log(`  Nametag: ${addr.nametag ?? 'none'}`);
  console.log(`  Created: ${new Date(addr.createdAt)}`);
}

// Switch to a new address (auto-tracked)
await sphere.switchToAddress(2);

// Register nametag for current address
await sphere.registerNametag('bob');

// Hide an address from UI
await sphere.setAddressHidden(1, true);

// Get all including hidden
const all = sphere.getAllTrackedAddresses();

// Get single address
const addr = sphere.getTrackedAddress(0);

// Listen for new address activations
sphere.on('address:activated', ({ address }) => {
  console.log(`New address tracked: #${address.index}`);
});

sphere.on('address:hidden', ({ index, addressId }) => {
  console.log(`Address #${index} hidden`);
});

L3 Payments

L3 is the primary payment layer. Transfers are sender-driven (v2): the sender's token engine certifies the transfer on-chain via the gateway and delivers a finished token to the recipient over the wallet-api mailbox — the recipient verifies it and stores it as 'confirmed' immediately.

Typical Wallet Flow

// 1. Init wallet (with v2 composition)
const { sphere } = await Sphere.init({ ...providers, autoGenerate: true, nametag: 'alice' });

// 2. Check what tokens we have
const assets = await sphere.payments.getAssets();
for (const asset of assets) {
  console.log(`${asset.symbol}: ${asset.totalAmount} (${asset.tokenCount} tokens)`);
}

// 3. Send tokens
const result = await sphere.payments.send({
  recipient: '@bob',
  amount: '1000000',
  coinId: 'UCT',
});

// 4. Listen for incoming transfers
sphere.on('transfer:incoming', (transfer) => {
  console.log(`Received from ${transfer.senderNametag}: ${transfer.tokens.length} tokens`);
});

// 5. Sync with remote storage (token backup)
await sphere.payments.sync();

// 6. View history
const history = sphere.payments.getHistory();

// 7. Cleanup
await sphere.destroy();

Get Balance & Assets

getBalance() is synchronous and returns TokenBalance[] — one entry per coin type.

// Get per-coin balances with confirmed/unconfirmed breakdown (synchronous)
const balances = sphere.payments.getBalance();

for (const bal of balances) {
  console.log(`${bal.symbol}:`);
  console.log(`  Confirmed:   ${bal.confirmedAmount} (${bal.confirmedTokenCount} tokens)`);
  console.log(`  Unconfirmed: ${bal.unconfirmedAmount} (${bal.unconfirmedTokenCount} tokens)`);
  console.log(`  Total:       ${bal.totalAmount}`);
}

// Filter to a single coin
const uctBalances = sphere.payments.getBalance('UCT_COIN_ID_HEX');

// Total portfolio value in USD (null if PriceProvider not configured)
const totalUsd = await sphere.payments.getFiatBalance();
console.log('Total USD:', totalUsd); // number | null

// Get assets grouped by coin, with price data
const assets = await sphere.payments.getAssets();
for (const asset of assets) {
  console.log(`${asset.symbol}: ${asset.totalAmount} (${asset.tokenCount} tokens)`);
  console.log(`  Price: $${asset.priceUsd ?? 'N/A'}`);
  console.log(`  Value: $${asset.fiatValueUsd?.toFixed(2) ?? 'N/A'}`);
  console.log(`  24h change: ${asset.change24h ?? 'N/A'}%`);
}

// Get assets for a specific coin
const uctAssets = await sphere.payments.getAssets('UCT');

Get Individual Tokens

// All tokens
const tokens = sphere.payments.getTokens();

for (const token of tokens) {
  console.log(`Token ${token.id}: ${token.amount} ${token.symbol}`);
  console.log(`  Coin ID: ${token.coinId}`);
}

// Filter by coin
const uctTokens = sphere.payments.getTokens({ coinId: 'UCT' });

// Get specific token by ID
const token = sphere.payments.getToken('token-id-123');

Send Tokens

// Send to nametag (resolved via Nostr)
const result = await sphere.payments.send({
  recipient: '@alice',
  amount: '1000000',
  coinId: 'UCT',
  memo: 'Payment for coffee',
});

// Send to DIRECT address
const result = await sphere.payments.send({
  recipient: 'DIRECT://0000be36...',
  amount: '500000',
  coinId: 'UCT',
});

// Send to chain pubkey (33-byte compressed secp256k1)
const result = await sphere.payments.send({
  recipient: '02abc123...',
  amount: '500000',
  coinId: 'UCT',
});

// Check result
console.log('Transfer ID:', result.id);
console.log('Status:', result.status);  // 'pending' | 'submitted' | 'confirmed' | 'delivered' | 'completed' | 'failed'
console.log('Delivery pending:', result.deliveryPending);  // true means on-chain but recipient mailbox delivery deferred
if (result.error) {
  console.error('Error:', result.error);
}

TransferRequest fields:

Field Required Description
recipient Yes @nametag, DIRECT://..., or chain pubkey
amount Yes Amount in smallest unit (string)
coinId Yes Token coin ID (e.g., 'UCT')
memo No Optional message to recipient

The recipient must have a published chain pubkey (Nostr identity binding) — otherwise send() throws INVALID_RECIPIENT. The v2 token engine must be available (oracle with a v2 trust base + gateway URL) — otherwise AGGREGATOR_ERROR.

Receive Tokens

Incoming tokens arrive automatically via the wallet-api mailbox. Subscribe to the event:

sphere.on('transfer:incoming', (transfer) => {
  console.log('Sender:', transfer.senderPubkey);
  console.log('Sender nametag:', transfer.senderNametag);
  console.log('Tokens:', transfer.tokens.length);
  console.log('Received at:', new Date(transfer.receivedAt));
});

For batch/CLI applications that need explicit receive (one-shot query):

// Fetch and process all pending incoming transfers
const { transfers } = await sphere.payments.receive();
console.log(`Received ${transfers.length} transfers`);

// With per-transfer callback
await sphere.payments.receive(undefined, (transfer) => {
  console.log(`Received ${transfer.tokens.length} tokens`);
});

Sync & Refresh

// Sync with all remote storage providers (including IPFS backup, if enabled)
// Merges local and remote token data
const syncResult = await sphere.payments.sync();
console.log(`Sync: +${syncResult.added} -${syncResult.removed}`);

// Validate tokens (v2 blobs via the engine; legacy v1 TXF via the oracle RPC)
const { valid, invalid } = await sphere.payments.validate();
console.log(`Valid: ${valid.length}, Invalid: ${invalid.length}`);

Transaction History

const history = sphere.payments.getHistory();

for (const entry of history) {
  console.log(`${entry.type}: ${entry.amount} ${entry.coinId}`);
  console.log(`  Date: ${new Date(entry.timestamp)}`);
  if (entry.recipientNametag) {
    console.log(`  To: @${entry.recipientNametag}`);
  }
}

Pending Transfers

// Get transfers that are still in progress
const pending = sphere.payments.getPendingTransfers();
for (const transfer of pending) {
  console.log(`${transfer.id}: ${transfer.status}`);
}

Peer Resolution

// Resolve any identifier to PeerInfo (nametag, address, pubkey)
const peer = await sphere.resolve('@alice');
if (peer) {
  console.log('Chain pubkey:', peer.chainPubkey);
  console.log('Direct address:', peer.directAddress);
  console.log('Nametag:', peer.nametag);
}

Price Provider (Optional)

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

// Set or replace PriceProvider at runtime
sphere.setPriceProvider(createPriceProvider({
  platform: 'coingecko',
  apiKey: userProvidedKey,  // Optional for free tier
  baseUrl: '/api/coingecko',  // CORS proxy for browser (see below)
}));

Without a PriceProvider, getBalance() returns null and price fields in getAssets() are null. All other functionality works normally.

CORS Proxy (Browser only): CoinGecko's free API lacks CORS headers. Add a proxy in development:

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api/coingecko': {
        target: 'https://api.coingecko.com/api/v3',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api\/coingecko/, ''),
      },
    },
  },
});

Then pass baseUrl: '/api/coingecko' in the price config. In production, use Nginx or a Cloudflare Worker as a reverse proxy. CoinGecko Pro API supports CORS natively and doesn't require a proxy.

Node.js environments are not subject to CORS — no proxy needed.

Reload Tokens

To reload tokens from storage (e.g., after external changes):

await sphere.payments.load();

How Transfers Work (v2, Sender-Driven)

When you call send(), the entire transfer is executed by the v2 token engine on the sender side:

  1. Engine spends the source token (or splits it when the exact amount is unavailable)
  2. Submits certification to the gateway, waits for the inclusion proof
  3. Produces a finished token blob addressed to the recipient's chain pubkey
  4. Delivers the token over the wallet-api mailbox as a V2_TRANSFER payload

The recipient verifies it (engine.verify + ownership check) and stores it as 'confirmed' — there is no receiver-side commitment submission, proof polling, or finalization phase.

Money safety: Every finished output blob is journaled (PENDING_V2_DELIVERIES) before the transport delivery and replayed by load() after a crash or transport failure, so the recipient's token is never lost. Source tokens whose spend was certified on-chain during a failed send become terminal 'spent' — they are never restored to the spendable pool. For splits, your change token is minted by the same on-chain operation and is immediately spendable (no placeholder, no background proof step).

Delivery pending: After send(), a transfer may have status === 'completed' and deliveryPending === true. This means the token is certified on-chain but the recipient's mailbox delivery is deferred (e.g., recipient offline, network congestion). This is normal and expected per §3.1 #621 of the covenant — the token is safe on-chain and will be delivered asynchronously.


Payment Requests

Payment requests allow you to request payment from another user and track the response.

Send Payment Request

// Request payment from @bob
const result = await sphere.payments.sendPaymentRequest('@bob', {
  amount: '1000000',
  coinId: 'UCT',
  message: 'Payment for order #1234',
});

if (result.success) {
  console.log('Request sent, ID:', result.requestId);
}

Wait for Response

// Send and wait for response (with timeout)
const result = await sphere.payments.sendPaymentRequest('@bob', {
  amount: '1000000',
  coinId: 'UCT',
  message: 'Coffee purchase',
});

if (result.success) {
  try {
    // Wait up to 2 minutes for response
    const response = await sphere.payments.waitForPaymentResponse(result.requestId!, 120000);

    switch (response.responseType) {
      case 'paid':
        console.log('Payment received! Transfer:', response.transferId);
        // Deliver the ticket
        break;
      case 'accepted':
        console.log('Request accepted, waiting for payment...');
        break;
      case 'rejected':
        console.log('Request rejected');
        break;
    }
  } catch (error) {
    console.log('Response timeout or cancelled');
  }
}

Subscribe to Responses

// React to all payment request responses
sphere.payments.onPaymentRequestResponse((response) => {
  console.log(`Response from ${response.responderPubkey}: ${response.responseType}`);

  if (response.responseType === 'paid') {
    // Handle successful payment
    deliverProduct(response.requestId);
  }
});

Handle Incoming Requests

// Listen for incoming payment requests
sphere.payments.onPaymentRequest((request) => {
  console.log(`${request.senderNametag} requests ${request.amount} ${request.symbol}`);
  console.log(`Message: ${request.message}`);

  // Show UI to user...
});

// Get pending requests
const pending = sphere.payments.getPaymentRequests({ status: 'pending' });

// Accept and pay a request
await sphere.payments.payPaymentRequest(requestId, 'Payment for ticket');

// Or reject
await sphere.payments.rejectPaymentRequest(requestId);

Track Outgoing Requests

// Get all outgoing requests
const outgoing = sphere.payments.getOutgoingPaymentRequests();

// Filter by status
const pendingOutgoing = sphere.payments.getOutgoingPaymentRequests({ status: 'pending' });

// Clear completed/expired requests
sphere.payments.clearCompletedOutgoingPaymentRequests();

Communications

Send Direct Message

const message = await sphere.communications.sendDM('@bob', 'Hello!');
console.log('Message ID:', message.id);

Get Conversations

const conversations = sphere.communications.getConversations();

for (const [peer, messages] of conversations) {
  console.log(`Conversation with ${peer}: ${messages.length} messages`);
}

Subscribe to Messages

// Direct messages
sphere.communications.onDirectMessage((message) => {
  console.log(`${message.senderNametag}: ${message.content}`);
});

// Broadcasts
sphere.communications.subscribeToBroadcasts(['news', 'updates']);
sphere.communications.onBroadcast((broadcast) => {
  console.log(`[${broadcast.tags}] ${broadcast.content}`);
});

Publish Broadcast

await sphere.communications.broadcast('Hello world!', ['general']);

Invoicing / Accounting

⚠️ Experimental — not production-ready. Invoicing/accounting is implemented and unit-tested, but it has no live/e2e verification, is used by no shipped app, and is not enabled in the Sphere wallet (nor supported over Connect — see CONNECT.md). NFT line-items and external delivery methods are placeholders. Treat the API as unstable.

The AccountingModule manages the full lifecycle of on-chain invoices: creation, sharing, payment attribution, status tracking, returns, and receipts. An invoice is a v2 data token minted via the token engine on the Unicity gateway. Its terms (payment targets, amounts, due date, memo) are embedded in the token's genesis data, making them tamper-evident and independently verifiable by any party who holds the token.

Invoice creation and import require the v2 token engine (oracle with a v2 trust base + gateway URL) — otherwise createInvoice() / importInvoice() throw INVOICE_ORACLE_REQUIRED.

Enable the Accounting Module

The module is opt-in. Pass accounting: true (or a config object) when initializing the wallet:

import { Sphere } from '@unicitylabs/sphere-sdk';
import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs';
import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api';

const baseProviders = createNodeProviders({ network: 'testnet', dataDir: './wallet', tokensDir: './tokens' });
const providers = createWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',
  network: 'testnet2',
  deviceId: 'my-device',
});

const { sphere } = await Sphere.init({
  ...providers,
  autoGenerate: true,
  accounting: true,  // Enable with defaults
});

// Access the module
const accounting = sphere.accounting!;

Custom configuration:

const { sphere } = await Sphere.init({
  ...providers,
  autoGenerate: true,
  accounting: {
    debug: true,                    // Enable debug logging
    autoTerminateOnReturn: false,   // Do not auto-close invoice when a return arrives (default)
    maxCoinDataEntries: 50,         // Max coin entries processed per token transaction (default)
  },
});

sphere.accounting is null when the module is not configured.

Create an Invoice

An invoice specifies one or more payment targets. Each target has a DIRECT:// destination address and a list of assets (coin type + amount) it expects to receive.

// Create a simple single-target invoice requesting 1 000 000 UCT
const result = await accounting.createInvoice({
  targets: [
    {
      address: sphere.identity!.directAddress!,  // Pay to this wallet
      assets: [
        { coin: ['UCT', '1000000'] },  // [coinId, amount in smallest units]
      ],
    },
  ],
  memo: 'Payment for order #1234',
  dueDate: Date.now() + 7 * 24 * 60 * 60 * 1000,  // Due in 7 days
});

if (!result.success) {
  console.error('Invoice creation failed:', result.error);
} else {
  console.log('Invoice ID:', result.invoiceId);      // 64-char hex token ID
  console.log('Terms:', result.terms);               // Parsed InvoiceTerms
  console.log('Token (hex blob):', result.token);    // Transmittable v2 invoice blob (string)
}

Multi-target invoices — where separate parties each receive a different asset — are supported by adding more entries to targets. Each target address must be a valid DIRECT:// address and must appear only once.

Share an Invoice

The result.token is the v2 invoice blob — a plain hex string. Share it with payers over any channel — DM, email, QR code, file, etc.:

// Send over DM (example — use your preferred transport)
await sphere.communications.sendDM('@bob', result.token);

Import an Invoice (Payer Side)

The payer imports the blob to start tracking the invoice locally:

const terms = await accounting.importInvoice(receivedHexBlob);
console.log('Invoice creator:', terms.creator);     // Chain pubkey of issuer
console.log('Due date:', terms.dueDate ? new Date(terms.dueDate) : 'none');
console.log('Targets:', terms.targets.length);

importInvoice() verifies the token via the engine and rejects tokens with an invalid or tampered proof chain. Legacy v1 TXF invoice tokens (objects / TXF JSON) are rejected with INVOICE_INVALID_DATA — v1 support was removed.

Pay an Invoice

Use payInvoice() to send tokens against a specific target and asset within the invoice. The module constructs the correct memo automatically and embeds your return address so the payee can issue refunds.

// Pay the first target's first asset (index 0, 0)
const transfer = await accounting.payInvoice(invoiceId, {
  targetIndex: 0,    // Which target to pay
  assetIndex: 0,     // Which asset within that target (default: 0)
  // amount is optional — defaults to the remaining uncovered amount
});

console.log('Transfer ID:', transfer.id);
console.log('Status:', transfer.status);  // 'pending' | 'submitted' | 'delivered' | 'completed' | 'failed'

Paying a specific amount (partial payment):

await accounting.payInvoice(invoiceId, {
  targetIndex: 0,
  amount: '500000',          // Pay half now
  freeText: 'First tranche', // Optional note appended to the memo
  refundAddress: sphere.identity!.directAddress!,  // Explicit refund destination
});

Check Invoice Status

getInvoiceStatus() computes the current payment state from local transaction history. It returns a full breakdown per target and per coin asset.

const status = await accounting.getInvoiceStatus(invoiceId);

console.log('State:', status.state);
// 'OPEN'      — no payments yet
// 'PARTIAL'   — some targets partially covered
// 'COVERED'   — all targets fully covered, awaiting confirmation
// 'CLOSED'    — explicitly or implicitly closed
// 'CANCELLED' — cancelled by a target party
// 'EXPIRED'   — due date passed, not yet covered

console.log('All confirmed:', status.allConfirmed);
console.log('Last activity:', new Date(status.lastActivityAt));

// Per-target breakdown
for (const target of status.targets) {
  console.log(`Target ${target.address}: covered=${target.isCovered}`);
  for (const coinAsset of target.coinAssets) {
    const [coinId, requested] = coinAsset.coin;
    console.log(`  ${coinId}: ${coinAsset.netCoveredAmount}/${requested}`);
    if (coinAsset.surplusAmount !== '0') {
      console.log(`  Surplus: ${coinAsset.surplusAmount}`);
    }
  }
}

getInvoiceStatus() has a side effect: when the invoice transitions from COVERED to fully confirmed (allConfirmed === true), it implicitly closes the invoice, freezes balances, and fires invoice:closed.

List Invoices

getInvoices() returns lightweight InvoiceRef objects (no status computed unless a state filter is passed):

// All invoices
const all = await accounting.getInvoices();

// Only open invoices created by this wallet
const mine = await accounting.getInvoices({
  state: 'OPEN',
  createdByMe: true,
  sortBy: 'dueDate',
  sortOrder: 'asc',
  limit: 20,
  offset: 0,
});

for (const ref of mine) {
  console.log(`${ref.invoiceId}: creator=${ref.isCreator} closed=${ref.closed} cancelled=${ref.cancelled}`);
  console.log(`  Memo: ${ref.terms.memo ?? 'none'}`);
  console.log(`  Due: ${ref.terms.dueDate ? new Date(ref.terms.dueDate) : 'none'}`);
}

// Single invoice by ID (synchronous, in-memory)
const ref = accounting.getInvoice(invoiceId);
if (!ref) {
  console.log('Invoice not found locally');
}

Close an Invoice (Payee Side)

Only a wallet whose address appears in terms.targets can close an invoice. Closing freezes the current balance snapshot and stops further dynamic recomputation.

// Explicit close — balance is frozen at this moment
await accounting.closeInvoice(invoiceId);

// Close and immediately return any surplus to payers
await accounting.closeInvoice(invoiceId, { autoReturn: true });

Implicit close happens automatically when getInvoiceStatus() detects state === 'COVERED' with allConfirmed === true.

Cancel an Invoice

Cancellation is also restricted to target parties. All received payments become eligible for return.

// Cancel and return all payments to payers
await accounting.cancelInvoice(invoiceId, { autoReturn: true });

Return a Payment (Manual)

The payee can return tokens to a specific payer at any time. The amount is capped at that payer's net balance for the given coin.

// Return 250 000 UCT to the original payer
const result = await accounting.returnInvoicePayment(invoiceId, {
  recipient: 'DIRECT://...',  // Payer's address (from status.targets[i].coinAssets[j].senderBalances)
  amount: '250000',
  coinId: 'UCT',
  freeText: 'Partial refund — order cancelled',
});

Auto-Return Setup

Auto-return automatically sends tokens back to payers when an invoice is terminated (closed or cancelled). It can be enabled globally or per invoice.

// Enable for all terminated invoices (global setting)
await accounting.setAutoReturn('*', true);

// Enable for a specific invoice only
await accounting.setAutoReturn(invoiceId, true);

// Check current settings
const settings = accounting.getAutoReturnSettings();
console.log('Global:', settings.global);
console.log('Per-invoice:', settings.perInvoice);

When enabled, auto-return fires immediately for invoices already in a terminal state, and continues to process new forward payments that arrive after termination.

Send Receipts (Payee Side)

After closing or cancelling an invoice, the payee can send structured receipt DMs to each payer confirming their contribution:

// Send receipt to all payers after close
const result = await accounting.sendInvoiceReceipts(invoiceId, {
  memo: 'Thank you for your payment — order dispatched',
  includeZeroBalance: false,  // Skip payers with net balance of 0 (default)
});

console.log(`Sent: ${result.sent}, Failed: ${result.failed}`);

Send Cancellation Notices

After cancelling an invoice, the payee can notify payers with a structured cancellation DM:

const result = await accounting.sendCancellationNotices(invoiceId, {
  reason: 'Item out of stock',
  dealDescription: 'Limited edition UCT merchandise',
  includeZeroBalance: false,
});

console.log(`Notices sent: ${result.sent}, Failed: ${result.failed}`);

Listen to Invoice Events

Subscribe via sphere.on() — the same event bus used by all other modules:

// Invoice created or imported
sphere.on('invoice:created', ({ invoiceId, confirmed }) => {
  console.log(`New invoice: ${invoiceId} (confirmed: ${confirmed})`);
});

// Incoming payment against an invoice
sphere.on('invoice:payment', ({ invoiceId, transfer, paymentDirection, confirmed }) => {
  console.log(`Payment on ${invoiceId}: direction=${paymentDirection}`);
});

// Asset fully covered (requested amount reached for a coin)
sphere.on('invoice:asset_covered', ({ invoiceId, address, coinId, confirmed }) => {
  console.log(`${coinId} covered for ${address} on invoice ${invoiceId}`);
});

// All assets for a target covered
sphere.on('invoice:target_covered', ({ invoiceId, address, confirmed }) => {
  console.log(`Target ${address} covered on invoice ${invoiceId}`);
});

// All targets covered
sphere.on('invoice:covered', ({ invoiceId, confirmed }) => {
  console.log(`Invoice ${invoiceId} fully covered`);
});

// Invoice closed (explicit: user called closeInvoice(); implicit: auto-close after COVERED+confirmed)
sphere.on('invoice:closed', ({ invoiceId, explicit }) => {
  console.log(`Invoice ${invoiceId} closed (explicit: ${explicit})`);
});

// Invoice cancelled
sphere.on('invoice:cancelled', ({ invoiceId }) => {
  console.log(`Invoice ${invoiceId} cancelled`);
});

// Overpayment detected on a coin asset
sphere.on('invoice:overpayment', ({ invoiceId, address, coinId, surplus, confirmed }) => {
  console.log(`Overpayment of ${surplus} ${coinId} on invoice ${invoiceId}`);
});

// Auto-return executed
sphere.on('invoice:auto_returned', ({ invoiceId, originalTransfer, returnTransfer }) => {
  console.log(`Auto-return sent for invoice ${invoiceId}`);
});

// Auto-return failed
sphere.on('invoice:auto_return_failed', ({ invoiceId, transferId, reason }) => {
  console.warn(`Auto-return failed for ${invoiceId}: ${reason}`);
});

// Return received by the payer
sphere.on('invoice:return_received', ({ invoiceId, transfer, returnReason }) => {
  console.log(`Return received on ${invoiceId}: reason=${returnReason}`);
});

// Receipt received (payer side — sent by the payee after close/cancel)
sphere.on('invoice:receipt_received', ({ invoiceId, receipt }) => {
  console.log(`Receipt for ${invoiceId} from ${receipt.targetAddress}`);
  console.log(`State: ${receipt.receipt.terminalState}`);
  for (const asset of receipt.receipt.senderContribution.assets) {
    console.log(`  ${asset.coinId}: forwarded=${asset.forwardedAmount} returned=${asset.returnedAmount}`);
  }
});

// Cancellation notice received (payer side)
sphere.on('invoice:cancellation_received', ({ invoiceId, notice }) => {
  console.log(`Invoice ${invoiceId} was cancelled: ${notice.notice.reason ?? 'no reason given'}`);
});

Complete Workflow Example

The following example walks through the full lifecycle from the payee's perspective (creating and closing) and the payer's perspective (importing and paying):

// ---- PAYEE SIDE ----

const { sphere: payee } = await Sphere.init({
  ...payeeProviders,
  autoGenerate: true,
  nametag: 'shop',
  accounting: true,
});
const payeeAccounting = payee.accounting!;

// 1. Create the invoice
const { invoiceId, token } = await payeeAccounting.createInvoice({
  targets: [{
    address: payee.identity!.directAddress!,
    assets: [{ coin: ['UCT', '2000000'] }],
  }],
  memo: 'Order #5678 — 2 items',
  dueDate: Date.now() + 3 * 24 * 60 * 60 * 1000,
});

// 2. Share with payer (here via DM — invoiceId + the hex blob)
await payee.communications.sendDM('@customer', JSON.stringify({ invoiceId, token }));

// 3. Listen for payments
payee.on('invoice:covered', async ({ invoiceId: id }) => {
  // All targets are paid — send receipt
  await payeeAccounting.sendInvoiceReceipts(id, { memo: 'Your order has been dispatched.' });
});

// ---- PAYER SIDE ----

const { sphere: payer } = await Sphere.init({
  ...payerProviders,
  autoGenerate: true,
  nametag: 'customer',
  accounting: true,
});
const payerAccounting = payer.accounting!;

// 4. Receive the invoice via DM
payer.communications.onDirectMessage(async (msg) => {
  let invoiceId, token;
  try { ({ invoiceId, token } = JSON.parse(msg.content)); } catch { return; }

  // 5. Import (hex blob string) and pay
  await payerAccounting.importInvoice(token);

  const status = await payerAccounting.getInvoiceStatus(invoiceId);
  console.log('Invoice state before payment:', status.state);  // 'OPEN'

  await payerAccounting.payInvoice(invoiceId, { targetIndex: 0 });
});

// 6. Payer receives a receipt DM once payee confirms
payer.on('invoice:receipt_received', ({ invoiceId: id, receipt }) => {
  console.log(`Invoice ${id} settled. Memo: ${receipt.receipt.memo}`);
});

Inspect the full transfer history attributed to an invoice, including transfers the module classified as irrelevant (wrong address, wrong asset, self-payment):

const transfers = accounting.getRelatedTransfers(invoiceId);

for (const t of transfers) {
  console.log(`${t.transferId}: direction=${t.direction} paymentDirection=${t.paymentDirection}`);
  console.log(`  Amount: ${t.amount} ${t.coinId}`);
  if ('reason' in t) {
    // IrrelevantTransfer — did not count toward coverage
    console.log(`  Irrelevant: ${t.reason}`);
  }
}

Parse an Invoice Memo

When inspecting raw transaction history (e.g., from sphere.payments.getHistory()), use parseInvoiceMemo() to check whether a memo references an invoice:

const parsed = accounting.parseInvoiceMemo('INV:abc123:F');
if (parsed) {
  console.log('Invoice ID:', parsed.invoiceId);           // 'abc123'
  console.log('Direction:', parsed.paymentDirection);     // 'forward'
  console.log('Free text:', parsed.freeText ?? 'none');
}

Accounting Error Codes

All accounting errors are SphereError instances with a typed .code. Use isSphereError() to handle them:

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

try {
  await accounting.closeInvoice(invoiceId);
} catch (err) {
  if (isSphereError(err)) {
    switch (err.code) {
      case 'INVOICE_NOT_FOUND':
        console.error('Invoice not found locally — was it imported?');
        break;
      case 'INVOICE_NOT_TARGET':
        console.error('Only target parties can close an invoice');
        break;
      case 'INVOICE_ALREADY_CLOSED':
        console.error('Invoice is already closed');
        break;
      case 'INVOICE_ALREADY_CANCELLED':
        console.error('Invoice is already cancelled');
        break;
      case 'INVOICE_TERMINATED':
        console.error('Cannot pay a closed or cancelled invoice');
        break;
      case 'INVOICE_RETURN_EXCEEDS_BALANCE':
        console.error('Return amount exceeds the payer\'s net balance for this coin');
        break;
      case 'INVOICE_MINT_FAILED':
        console.error('Aggregator submission failed — check network connection');
        break;
      case 'INVOICE_NOT_TERMINATED':
        console.error('Receipts can only be sent for closed or cancelled invoices');
        break;
      case 'COMMUNICATIONS_UNAVAILABLE':
        console.error('DM receipts require the CommunicationsModule to be available');
        break;
      default:
        console.error(err.message);
    }
  }
}

Full list of accounting error codes:

Code Thrown by Meaning
INVOICE_NO_TARGETS createInvoice Targets array is empty
INVOICE_NO_ASSETS createInvoice A target has no assets
INVOICE_INVALID_AMOUNT createInvoice, payInvoice Asset amount is not a positive integer, or zero-amount send
INVOICE_INVALID_ADDRESS createInvoice A target address is not a valid DIRECT:// address
INVOICE_ORACLE_REQUIRED createInvoice, importInvoice Token engine unavailable (v2 oracle config with trust base + gateway required)
INVOICE_MINT_FAILED createInvoice Gateway submission failed
INVOICE_INVALID_PROOF importInvoice Inclusion proof is invalid or tampered
INVOICE_INVALID_DATA importInvoice Token data cannot be parsed as InvoiceTerms, or a legacy v1 TXF token was supplied
INVOICE_ALREADY_EXISTS importInvoice Invoice token already held locally
INVOICE_NOT_FOUND Most methods Invoice is not known locally
INVOICE_NOT_TARGET closeInvoice, cancelInvoice, returnInvoicePayment, sendInvoiceReceipts, sendCancellationNotices Wallet is not one of the invoice targets
INVOICE_ALREADY_CLOSED closeInvoice Invoice is already in CLOSED state
INVOICE_ALREADY_CANCELLED cancelInvoice Invoice is already in CANCELLED state
INVOICE_TERMINATED payInvoice Invoice is CLOSED or CANCELLED — no further payments accepted
INVOICE_RETURN_EXCEEDS_BALANCE returnInvoicePayment Return amount exceeds per-sender net balance
INVOICE_NOT_TERMINATED sendInvoiceReceipts Invoice must be CLOSED or CANCELLED before sending receipts
INVOICE_NOT_CANCELLED sendCancellationNotices Invoice must be in CANCELLED state
COMMUNICATIONS_UNAVAILABLE sendInvoiceReceipts, sendCancellationNotices CommunicationsModule is not available
INVOICE_MEMO_TOO_LONG createInvoice, sendInvoiceReceipts, sendCancellationNotices Memo or reason exceeds 4096 characters
RATE_LIMITED setAutoReturn('*', ...) Global auto-return setting changed within 5-second cooldown

Token Sync (IPFS Optional Backup)

IPFS sync provides optional decentralized token backup. It is NOT a payment delivery mechanism (v2 transfers use the wallet-api mailbox for delivery). IPFS sync merges local and remote token copies for resilience and cross-device recovery.

Enable IPFS at Initialization

const baseProviders = createBrowserProviders({
  network: 'testnet',
  tokenSync: {
    ipfs: { enabled: true },
  },
});

const providers = createWalletApiProviders(baseProviders, {
  baseUrl: 'https://wallet-api.unicity.network',
  network: 'testnet2',
  deviceId: 'my-device',
});

Add IPFS Sync at Runtime

import { createBrowserIpfsStorageProvider } from '@unicitylabs/sphere-sdk/impl/browser/ipfs';
// For Node.js: import { createNodeIpfsStorageProvider } from '@unicitylabs/sphere-sdk/impl/nodejs/ipfs';

const ipfsProvider = createBrowserIpfsStorageProvider({ debug: true });
await sphere.addTokenStorageProvider(ipfsProvider);

// Sync merges local and remote token data
const result = await sphere.payments.sync();
console.log(`Added: ${result.added}, Removed: ${result.removed}`);

How It Works

  • Local tokens are published to IPFS/IPNS for backup.
  • Remote tokens from other devices are merged into the local store.
  • Sync is idempotent and automatic on sphere.payments.sync() or as part of background refresh.

IPFS sync is useful for: - Backup resilience (tokens not lost if a device dies) - Cross-device token recovery (e.g., phone → laptop) - Offline-first applications (publish when connected, merge when reconnected)


Custom Providers

Storage Provider Interface

The default browser implementation is IndexedDBStorageProvider (database: sphere-storage, object store: kv). For Node.js, FileStorageProvider is used. Both support per-address key scoping via setIdentity().

interface StorageProvider {
  connect(): Promise<void>;
  disconnect(): Promise<void>;
  isConnected(): boolean;
  getStatus(): ProviderStatus;

  setIdentity(identity: FullIdentity): void;
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
  remove(key: string): Promise<void>;
  has(key: string): Promise<boolean>;
  keys(prefix?: string): Promise<string[]>;
  clear(prefix?: string): Promise<void>;

  // Tracked addresses registry
  saveTrackedAddresses(entries: TrackedAddressEntry[]): Promise<void>;
  loadTrackedAddresses(): Promise<TrackedAddressEntry[]>;
}

Transport Provider Interface

interface TransportProvider {
  connect(): Promise<void>;
  disconnect(): Promise<void>;

  setIdentity(identity: FullIdentity): void;
  sendMessage(recipientPubkey: string, content: string): Promise<string>;
  onMessage(callback: (msg: IncomingMessage) => void): () => void;
  sendTokenTransfer(recipientPubkey: string, payload: TokenTransferPayload): Promise<string>;
  onTokenTransfer(handler: TokenTransferHandler): () => void;

  // Peer resolution (optional)
  resolve?(identifier: string): Promise<PeerInfo | null>;
  resolveNametagInfo?(nametag: string): Promise<PeerInfo | null>;
  resolveAddressInfo?(address: string): Promise<PeerInfo | null>;

  // Identity binding (optional)
  publishIdentityBinding?(chainPubkey: string, directAddress: string, nametag?: string): Promise<boolean>;

  // Broadcast (optional)
  publishBroadcast?(content: string, tags?: string[]): Promise<string>;
  subscribeToBroadcast?(tags: string[], callback: (b: IncomingBroadcast) => void): () => void;
}

Oracle Provider Interface

Post v1-cutover the oracle is a thin network-config provider for the v2 token engine: it loads the root trust base (JSON) and exposes the gateway URL + API key. The engine builds its own clients from these — custom implementations MUST provide the three config accessors.

interface OracleProvider {
  connect(): Promise<void>;
  disconnect(): Promise<void>;
  isConnected(): boolean;
  getStatus(): ProviderStatus;

  /** Loads the trust base JSON (via the platform loader when not passed explicitly). */
  initialize(trustBaseJson?: unknown): Promise<void>;

  /** Best-effort JSON-RPC check for LEGACY v1 TXF tokens (display path only). */
  validateToken(tokenData: unknown): Promise<ValidationResult>;

  // v2 token-engine config surface (REQUIRED)
  getTrustBaseJson(): unknown | null;   // raw trust-base JSON (networkId comes from it)
  getAggregatorUrl(): string;           // gateway (aggregator) base URL
  getApiKey(): string | undefined;      // gateway API key, when required (e.g. testnet2)
}

Events

Available Events

// Transfer events
sphere.on('transfer:incoming', (transfer) => { });
sphere.on('transfer:confirmed', (transfer) => { });
sphere.on('transfer:failed', (transfer) => { });

// Payment request events
sphere.on('payment_request:incoming', (request) => { });
sphere.on('payment_request:accepted', (request) => { });
sphere.on('payment_request:rejected', (request) => { });
sphere.on('payment_request:paid', (request) => { });
sphere.on('payment_request:response', (response) => { });

// Message events
sphere.on('message:dm', (message) => { });
sphere.on('message:broadcast', (broadcast) => { });

// Sync events
sphere.on('sync:started', ({ source }) => { });
sphere.on('sync:completed', ({ source, count }) => { });
sphere.on('sync:provider', ({ providerId, success, added, removed, error }) => { });
sphere.on('sync:error', ({ source, error }) => { });
sphere.on('sync:remote-update', ({ providerId, name, sequence, cid, added, removed }) => { });

// Connection events
sphere.on('connection:changed', ({ provider, connected }) => { });
sphere.on('nametag:registered', ({ nametag, addressIndex }) => { });
sphere.on('nametag:recovered', ({ nametag }) => { });

// Identity events
sphere.on('identity:changed', ({ directAddress, chainPubkey, nametag, addressIndex }) => { });

// Address tracking events
sphere.on('address:activated', ({ address }) => { });  // New address tracked
sphere.on('address:hidden', ({ index, addressId }) => { });
sphere.on('address:unhidden', ({ index, addressId }) => { });

Unsubscribe

const unsubscribe = sphere.on('transfer:incoming', handler);

// Later...
unsubscribe();

Nametags (Unicity IDs)

Nametags provide human-readable addresses (e.g., @alice) for receiving tokens. A nametag is a Nostr identity binding (name ↔ chainPubkey) — receive is always locked to your chain pubkey; there is no PROXY address scheme.

Registration Flow

// Register during wallet creation
const { sphere } = await Sphere.init({
  ...providers,
  mnemonic: 'your twelve words...',
  nametag: 'alice',
});

// Or register after wallet is created
await sphere.registerNametag('alice');

// Check availability first (no binding resolves for the name)
const available = await sphere.isNametagAvailable('alice');

Registration also mints + stores a self-issued v2 UnicityIdToken as an on-chain claim (best-effort and idempotent — a gateway outage never fails registration; the claim is re-minted on a later load if missing). The claim is not used at runtime — name resolution stays Nostr-binding-only.

Multi-Address Nametags

Each derived address can have its own nametag:

// Register @alice for address 0
await sphere.registerNametag('alice');

// Switch to address 1 and register @bob
await sphere.switchToAddress(1);
await sphere.registerNametag('bob');

// Query nametags
sphere.getNametagForAddress(0);  // 'alice'
sphere.getNametagForAddress(1);  // 'bob'
sphere.getAllAddressNametags();  // Map { 0 => 'alice', 1 => 'bob' }

Troubleshooting: "Nametag already taken"

Error:

Failed to register nametag. It may already be taken.
[NostrTransportProvider] Nametag already taken: myname - owner: f124f93ae6...

Cause: The nametag is registered to a different public key. This happens when:

  1. Storage cleared or inaccessibleSphere.exists() returns false → new wallet created
  2. Different mnemonic provided on subsequent runs

Note: autoGenerate: true does NOT generate new mnemonic every restart. It only generates if Sphere.exists() returns false.

Solution:

// Use persistent file storage (recommended for backend)
import { FileStorageProvider } from '@unicitylabs/sphere-sdk/impl/nodejs';

const storage = new FileStorageProvider('./wallet-data');
const { sphere } = await Sphere.init({
  storage,  // Persists mnemonic to disk
  autoGenerate: true,
  nametag: 'myservice',
});

// Or use fixed mnemonic from environment
const { sphere } = await Sphere.init({
  ...providers,
  mnemonic: process.env.WALLET_MNEMONIC,
  nametag: 'myservice',
});

Debug storage issues:

const exists = await Sphere.exists(storage);
console.log('Wallet exists:', exists);  // Should be true after first run

// Enable storage debug logs
logger.setTagDebug('LocalStorage', true);
logger.setTagDebug('IndexedDB', true);
logger.setTagDebug('IPFS-Storage', true);

Nametag Sync on Load

When loading an existing wallet, the SDK automatically syncs the nametag with Nostr:

// On Sphere.load(), if local nametag exists:
// 1. Checks if nametag is registered on Nostr
// 2. If not registered or owned by this pubkey, re-publishes it
// 3. Logs warning if owned by different pubkey

Nametag Recovery on Import

When importing a wallet without specifying a nametag, the SDK automatically attempts to recover it from Nostr:

// Import wallet - nametag will be recovered if found on Nostr
const { sphere } = await Sphere.init({
  ...providers,
  mnemonic: 'your twelve words...',
  // No nametag specified
});

// Listen for recovery
sphere.on('nametag:recovered', ({ nametag }) => {
  console.log('Recovered nametag:', nametag);
});

// Or check after init
if (sphere.identity?.nametag) {
  console.log('Nametag recovered:', sphere.identity.nametag);
}

The recovery process: 1. Derives transport pubkey from wallet keys 2. Queries Nostr for nametag events owned by this pubkey 3. If found, sets the nametag locally and emits nametag:recovered event


Error Handling

Send Error Handling

send() returns a TransferResult — check its status and error fields:

const result = await sphere.payments.send({
  recipient: '@alice',
  amount: '1000000',
  coinId: 'UCT',
});

if (result.status === 'failed') {
  console.error('Transfer failed:', result.error);
  // Common errors:
  // - Insufficient balance
  // - Recipient not found (nametag not registered)
  // - Network/aggregator errors
}

Token Validation

// Validate all tokens (v2 blobs via the engine: verify + spent check)
const { valid, invalid } = await sphere.payments.validate();

if (invalid.length > 0) {
  console.warn(`${invalid.length} tokens marked invalid`);
  for (const token of invalid) {
    console.warn(`  ${token.id}: ${token.amount} ${token.symbol}`);
  }
}

// Subscribe to transfer lifecycle events
sphere.on('transfer:confirmed', (transfer) => {
  console.log('Transfer confirmed:', transfer.id);
});

sphere.on('transfer:failed', (transfer) => {
  console.error('Transfer failed:', transfer.id, transfer.error);
});

Typed Error Handling

All SDK methods throw SphereError with a typed .code field. Use isSphereError() type guard to handle errors programmatically:

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

try {
  await sphere.payments.send({ coinId, amount, recipient });
} catch (err) {
  if (isSphereError(err)) {
    // err.code is typed as SphereErrorCode
    switch (err.code) {
      case 'INSUFFICIENT_BALANCE':
        showError('Not enough funds');
        break;
      case 'INVALID_RECIPIENT':
        showError('Recipient not found');
        break;
      case 'TRANSPORT_ERROR':
        showError('Network issue');
        break;
      case 'AGGREGATOR_ERROR':
        showError('Oracle unavailable');
        break;
      default:
        showError(err.message);
    }
  }
}

Debug Logging

Enable the centralized logger to diagnose issues:

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

logger.configure({ debug: true });

// Or enable specific modules:
logger.setTagDebug('Payments', true);
logger.setTagDebug('Nostr', true);

Best Practices

1. Always Handle Wallet State

async function initApp() {
  const baseProviders = createBrowserProviders({ network: 'testnet' });
  const providers = createWalletApiProviders(baseProviders, {
    baseUrl: 'https://wallet-api.unicity.network',
    network: 'testnet2',
    deviceId: 'my-device',
  });

  // Sphere.init() handles both creation and loading
  const { sphere, created, generatedMnemonic } = await Sphere.init({
    ...providers,
    autoGenerate: true,
  });

  if (created && generatedMnemonic) {
    // Show mnemonic backup UI
    console.log('Save your mnemonic:', generatedMnemonic);
  }
}

2. Subscribe to Events Early

// Sphere.init() returns an initialized sphere — subscribe to events right after
const { sphere } = await Sphere.init({ ...providers, autoGenerate: true });

sphere.on('transfer:incoming', handleIncomingTransfer);
sphere.on('message:dm', handleMessage);

3. Graceful Shutdown

window.addEventListener('beforeunload', async () => {
  await sphere.destroy();
});

4. Handle Reconnection

sphere.on('connection:changed', async ({ provider, connected }) => {
  if (!connected) {
    console.log(`${provider} disconnected, attempting reconnect...`);
    // SDK handles reconnection automatically
  }
});

5. Event Timestamp Persistence

The transport layer persists the timestamp of the last processed wallet event. On reconnect or app restart, only events newer than the stored timestamp are fetched — preventing duplicate token processing.

This is handled automatically when using createBrowserProviders() or createNodeProviders(). The storage provider is passed to the transport, and timestamps are persisted per wallet pubkey.

Behavior by scenario:

Scenario since filter
Existing wallet with stored timestamp Resume from last event timestamp
Fresh wallet (no stored timestamp) now — no historical events
No storage adapter (legacy) now - 24h fallback

Note: The since filter only applies to wallet events (token transfers, payment requests). Chat messages (NIP-17 GIFT_WRAP) are always real-time with no since filter.


Testing

The SDK includes a comprehensive test suite using Vitest.

Running Tests

# Run all tests (watch mode)
npm test

# Run once (CI mode)
npm run test:run

# Run specific test file
npx vitest run tests/unit/core/crypto.test.ts

# E2E tests against live testnet2 (requires .env — see .env.example)
npm run test:e2e

# Run with coverage
npm test -- --coverage

Test Coverage

The suite spans ~170 test files. Major areas:

Area Description
tests/unit/core Crypto (BIP39/BIP32), bech32, currency, encryption, Sphere lifecycle
tests/unit/token-engine v2 engine adapter: mint, transfer, split, verify, Unicity ID mint
tests/unit/modules PaymentsModule (v2 send/receive/mint/validate, spend queue, history), AccountingModule (full invoice lifecycle), Communications, GroupChat, Market
tests/unit/serialization TXF format, wallet text/dat backups
tests/unit/transport Nostr P2P messaging, event timestamp persistence
tests/unit/validation Engine-based TokenValidator
tests/unit/impl Storage providers (IndexedDB, file), config resolvers, IPFS
tests/integration Wallet import/export, nametag round-trips
tests/e2e Live testnet2 flows (gated behind .env keys; skipped otherwise)

Writing Tests

Tests follow the structure:

tests/
├── unit/
│   ├── core/            # crypto, bech32, currency, encryption, Sphere.*
│   ├── token-engine/    # v2 engine adapter + Unicity ID minter
│   ├── modules/         # PaymentsModule.*, AccountingModule.*, Communications*, ...
│   ├── price/
│   ├── transport/
│   ├── serialization/
│   ├── validation/
│   ├── connect/
│   └── impl/            # browser / nodejs / shared providers
├── integration/
├── e2e/                 # live-network tests (vitest.e2e.config.ts)
├── relay/
└── fixtures/

Example test:

import { describe, it, expect } from 'vitest';
import { generateMnemonic, validateMnemonic } from '../../../core/crypto';

describe('generateMnemonic()', () => {
  it('should generate valid 12-word mnemonic', () => {
    const mnemonic = generateMnemonic(12);
    const words = mnemonic.split(' ');

    expect(words).toHaveLength(12);
    expect(validateMnemonic(mnemonic)).toBe(true);
  });
});