The project's README from github.com/unicity-sphere/sphere-sdk. As one markdown file: README.md.
A modular TypeScript SDK for Unicity wallet operations (Unicity state transition network).
ConnectClient / ConnectHost (browser extension + popup)npm install @unicitylabs/sphere-sdk
Choose your platform:
| Platform | Guide | Required | Optional |
|---|---|---|---|
| Browser | QUICKSTART-BROWSER.md | SDK only | IndexedDB storage |
| Node.js | QUICKSTART-NODEJS.md | SDK + ws |
File storage |
| CLI | @unicity-sphere/cli | Separate package | - |
| dApp integration | CONNECT.md | SDK only | Sphere extension |
The CLI has moved to a dedicated package: @unicity-sphere/cli.
npm install -g @unicity-sphere/cli
sphere --help
See docs/QUICKSTART-CLI.md for the full command reference.
Setup is two provider layers, not one.
createBrowserProviders/createNodeProvidersbuild only the base (storage + transport + oracle). You must then attach the wallet-api transport config withcreateWalletApiProviders— money moves only through the wallet-api vertical. Skipping it fails loudly:Sphere.initthrowsINVALID_CONFIG.
import { Sphere } from '@unicitylabs/sphere-sdk';
import { createBrowserProviders } from '@unicitylabs/sphere-sdk/impl/browser';
import { createWalletApiProviders } from '@unicitylabs/sphere-sdk/impl/shared/wallet-api';
// 1. Base providers: storage + transport + oracle. `network` is REQUIRED here (no default).
// The testnet2 gateway key is PUBLIC (not a secret); it is required at runtime for send/mint.
const base = createBrowserProviders({
network: 'testnet', // alias of testnet2 (networkId 4)
oracle: { apiKey: 'sk_ddc3cfcc001e4a28ac3fad7407f99590' }, // public testnet2 key
});
// 2. Attach the wallet-api transport config. Returns { ...base, walletApi } —
// a plain config object ({ network, baseUrl, deviceId?, ... }) that Sphere.init consumes.
const providers = createWalletApiProviders(base, {
baseUrl: 'https://wallet-api.unicity.network', // your wallet-api deployment (testnet2)
network: 'testnet2',
deviceId: 'my-stable-device-id', // persist this to avoid re-auth each launch
});
// 3. Init the wallet (auto-creates one if none exists).
const { sphere, created, generatedMnemonic } = await Sphere.init({
...providers,
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.
const result = await sphere.payments.send({
recipient: '@alice',
amount: '1000000', // decimal STRING — never a JS number
coinId: 'UCT', // a symbol auto-resolves to its hex coinId
memo: 'hello',
});
console.log(result.status); // 'completed'
// result.deliveryPending === true is NORMAL, not a failure: the token is certified on-chain but
// the recipient's mailbox delivery was deferred and will land on retry (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));
console.log(await sphere.payments.assets());
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 |
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 — the three must agree. Sphere.init resolves the payments composition and the token registry from its own network, so omitting it or letting it disagree with walletApi.network throws INVALID_CONFIG before any storage write.For manual/advanced provider wiring, see Custom Providers Configuration. For the deeper integration guide, see docs/INTEGRATION.md.
TransferResult)send() resolves with a TransferResult:
| Field | Meaning |
|---|---|
status |
'completed' on success. ('pending' | 'submitted' | 'confirmed' | 'delivered' | 'failed' also exist for in-flight/terminal states.) |
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). |
Treat status === 'completed' as sent. 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).
send() rejections — CERTIFICATION_UNCONFIRMED is NOT re-sendable (money-safety)send() throws for genuine failures (INVALID_RECIPIENT, insufficient balance, a TransferConflictError lost race) and for one indeterminate case you must handle specially: a ProofUnconfirmedError (code: 'CERTIFICATION_UNCONFIRMED', mayHaveCertified: true). It means the spend may already be on-chain but the proof fetch was inconclusive — the SDK keeps the intent open and completes it later under the same transferId.
send() on CERTIFICATION_UNCONFIRMED. A fresh send() mints a new transferId on a different source, so the original resumes and the retry sends → the recipient is double-paid. Treat it as "sent, pending confirmation."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). There is no public resume API to call.import { isSphereError } from '@unicitylabs/sphere-sdk';
try {
const result = await sphere.payments.send({ recipient: '@bob', amount, coinId });
// result.status === 'completed' (or result.deliveryPending === true) → sent
} catch (err) {
if (isSphereError(err) && err.code === 'CERTIFICATION_UNCONFIRMED') {
// Possibly already sent on-chain — DO NOT re-send. Resume finishes it.
} else {
// genuine failure — safe to surface to the user / retry
}
}
sphere.paymentsV2The 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.
The accounting: / swap: options are not part of this cleanup: they still throw a typed
INVALID_CONFIG, deliberately, because 0.15.0 is the release where consumers re-integrate.
The SDK ships network presets that configure all services automatically. network is required — there is no default:
| Network | Aggregator (gateway) | Nostr Relay |
|---|---|---|
testnet |
gateway.testnet2.unicity.network (v2) | nostr-relay.testnet.unicity.network |
testnet2 |
alias of testnet (same configuration) |
nostr-relay.testnet.unicity.network |
mainnet |
gateway.mainnet.unicity.network (v3) | nostr-relay.testnet.unicity.network (shared until mainnet has its own) |
Live networks are testnet2 and mainnet.
testnetis an alias of testnet2 (network id 4, taken from the trust base; own testnet2 token registry);mainnetis network id 1. The v1 network is discontinued — the oldgoggregator-testtestnet spoke the removed v1 protocol, and thedevnetwork that aliased its trust base has been removed along with every other v1 pointer. Mainnet has no wallet-api deployment yet, so its money path is not reachable even though the chain and gateway are live. The transfer wire payload is the finished token blob — the base SDK's ownToken.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 testnet for all services
const providers = createBrowserProviders({ network: 'testnet' });
// Override specific services while using network preset
const providers = createBrowserProviders({
network: 'testnet',
oracle: { url: 'https://custom-gateway.example.com' }, // custom testnet2 gateway
});
The SDK bundles no default API key. Pass the gateway key via oracle: { apiKey } — without it, gateway requests are unauthenticated and money movement on testnet2 fails.
const providers = createBrowserProviders({
network: 'testnet',
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.
The testnet 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 | testnet (alias testnet2), networkId 4 |
| 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.
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 providers = createBrowserProviders({
network: 'testnet',
price: { platform: 'coingecko' },
});
// With CoinGecko Pro
const providers = createBrowserProviders({
network: 'testnet',
price: { platform: 'coingecko', apiKey: 'CG-xxx' },
});
const { sphere } = await Sphere.init({ ...providers, 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',
}));
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 { getCoinIdBySymbol } from '@unicitylabs/sphere-sdk';
// Resolve the coin's hex id from the token registry (or pass a hex coinId directly)
const coinId = getCoinIdBySymbol('UCT');
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 requires a working oracle config (trust base + gateway URL + API key) — it fails with an error result otherwise. See API Key above.
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 nametag for specific address
const bobNametag = sphere.getNametagForAddress(1); // 'bob'
// Get all address nametags
const allNametags = sphere.getAllAddressNametags();
// Map { 0 => 'alice', 1 => 'bob' }
// Derive address without switching (for display/receiving)
const addr2 = sphere.deriveAddress(2);
console.log(addr2.address, addr2.publicKey);
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; // IPNS name for token sync
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)
// Listen for address switches
sphere.on('identity:changed', (event) => {
console.log('Switched to address index:', event.data.addressIndex);
console.log('L3 address:', event.data.directAddress);
console.log('Chain pubkey:', event.data.chainPubkey);
console.log('Nametag:', event.data.nametag);
});
// Listen for nametag recovery (when importing wallet)
sphere.on('nametag:recovered', (event) => {
console.log('Recovered nametag from Nostr:', event.data.nametag);
});
Request payments from others over the wallet-api rail (sphere.payments.requests). Request memos ride an encrypted recipient-ECDH envelope; a pay() is durably settling before any possibly-committed error can surface, so a crash never double-pays:
// Send a payment request
const result = await sphere.payments.requests.create('@bob', {
coinId: 'UCT',
amount: '1000000',
memo: 'Payment for order #1234',
});
// Track status via the event stream
sphere.on('payment_request:updated', ({ id, status }) => {
// status: 'pending' | 'settling' | 'paid' | 'rejected' | 'expired'
if (id === result.requestId && status === 'paid') console.log('Payment received!');
});
// Handle incoming payment requests
sphere.on('payment_request:incoming', async (request) => {
console.log(`${request.senderNametag} requests ${request.amount} ${request.symbol}`);
// Accept and pay
await sphere.payments.requests.pay(request.id);
// Or decline (a server 403/409 propagates — a refused decline is not success)
await sphere.payments.requests.decline(request.id);
});
// Current views + housekeeping
const open = sphere.payments.requests.list();
sphere.payments.requests.dismissProcessed();
Relay-based group messaging using the NIP-29 protocol. The module embeds its own Nostr connection separate from the wallet transport.
// Enable with network defaults (wss://sphere-relay.unicity.network)
const { sphere } = await Sphere.init({
...providers,
autoGenerate: true,
groupChat: true,
});
// Enable with custom relay
const { sphere } = await Sphere.init({
...providers,
autoGenerate: true,
groupChat: { relays: ['wss://my-nip29-relay.com'] },
});
// Access the module
const gc = sphere.groupChat!;
// 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.isCurrentUserRela