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/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, 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; thepaymentsV2Transportseam in thewalletApiconfig lets tests/custom hosts inject a whole replacement bundle. networkplacement. Required oncreateBrowserProviders/createNodeProviders, in thewalletApiconfig, AND onSphere.init; use one literal,'testnet2', in all three places. Neither provider factory returns anetworkfield, so...providerscannot supply it.Sphere.initcompares its ownnetworkwithwalletApi.networkas plain strings and throwsINVALID_CONFIG("walletApi.network "testnet2" does not match the Sphere network ...") when they differ, including whenSphere.initgets nonetworkat 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 theSphere.initnetwork, 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 orpv2g2:key.networkis still required, because it selects the token registry and the group-chat relays.sphere.paymentsthen throwsPAYMENTS_NOT_COMPOSEDandsphere.hasPaymentsisfalse. OmittingwalletApialtogether still throwsINVALID_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_UNCONFIRMEDis aProofUnconfirmedError(mayHaveCertified: true): the spend may already be on-chain but the proof fetch was inconclusive.SEND_SYNC_PENDINGcan 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 topendingTransfers()),INVALID_RECIPIENT,TRANSPORT_ERROR(the recipient lookup could not reach the relay) andVALIDATION_ERROR(a bad amount).INSUFFICIENT_BALANCEis never thrown. - Import the error helpers from the same entry point as
Sphere, and readcodestructurally for the clean failures: errors thrown by provider code (the./impl/*bundles, for example the Nostr transport during the recipient lookup) are a differentSphereErrorclass copy, soisSphereError()isfalsefor 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.
testnetis 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 thenetworkcheck against a'testnet2'wallet-api config (seenetworkplacement).SPHERE_NETWORKSexposes onlymainnetandtestnet2. 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. On mainnet usenetwork: 'mainnet'increateBrowserProviders/createNodeProviders, in thewalletApiconfig and onSphere.init, the mainnet wallet-apihttps://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 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 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.initbuilds from the oracle's trust base and gateway URL (without themSphere.initrejects withINVALID_CONFIG); pass the gateway key viaoracle: { apiKey }. A mint that fails after it was journaled resolves{ success: false, error }and is replayed by the SDK: do not callmint()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 keyderiveAddress(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<addressId, Map<nametagIndex, nametag>>, 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 listeningNametag 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? }. Checksuccess.coinIdis the 64-hex coin id (look it up withgetCoinIdBySymbol()).- Never pay from inside the
payment_request:incominghandler without the user's decision;pay()anddecline()are alternatives.pay()rethrowssend()'s errors: handle them as in Handlingsend()rejections. payment_request:updatedreports requests you received. The SDK does not track requests you created: detect payment throughtransfer:incomingorsphere.payments.history().request.amountis a base-unit string andrequest.coinIdthe hex id;request.symbolis 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'(README truncated)