npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@unspendablelabs/horizon-market-client

v0.3.1

Published

TypeScript client for the Horizon Market Atomic Swap API

Readme

CI codecov npm version License: MIT TypeScript

@unspendablelabs/horizon-market-client

TypeScript client for the Horizon Market Atomic Swap API.

The API never receives your private key. Write operations use signed PSBTs (sell / buy / fee) or a BIP322 message signature (delist).

Install

npm install @unspendablelabs/horizon-market-client

For the optional React UI (web or React Native), also install peer dependencies:

npm install react
# React Native apps only:
npm install react-native
# Optional native peers — wallet/brand icons and address copy:
npm install react-native-svg expo-clipboard

CLI

The package ships the horizon CLI (apps/cli) as its bin — a global install puts it on your PATH:

npm install -g @unspendablelabs/horizon-market-client
horizon --help    # init / list / balances / sell / buy / send

Quote → sign → submit

Every write workflow follows the same pattern: the server composes unsigned PSBTs (or a delist message), you sign locally, then submit.

flowchart LR
  subgraph sell [Sell]
    SQ[sell-quotes] --> SS[Sign swap + fee PSBTs]
    SS --> SP[Sign + finalize prep if needed]
    SP --> SC[POST /atomic-swaps]
  end
  subgraph buy [Buy]
    BQ[buy-quotes] --> BS[Sign buyer PSBT]
    BS --> BP[POST /purchases]
  end
  subgraph delist [Delist]
    DS[start delist] --> DM[BIP322 sign request id]
    DM --> DC[PUT confirm]
  end

| Step | Sell | Buy | Delist | |------|------|-----|--------| | Quote | POST sell-quotes | POST buy-quotes | — | | Sign | prep_psbt (finalize if present) + swap_psbt + fee_psbt | psbt (buyer inputs only) | BIP322 on delist id | | Submit | POST /atomic-swaps | POST /purchases | PUT delist-requests/{id} |

Use the high-level workflow methods (openSellOrder, fillSwaps, delistSwap) or the REST helpers for manual control.

For manual sell flows, await signAndFinalizeSellPrep(quote, signer, btcNetwork) signs and finalizes attach or zeld transfer prep PSBTs from a sell quote (btcNetwork is a bitcoinjs-lib Network object — see examples/sell.ts). It is async because a Signer may sign asynchronously (see below).

Asynchronous signers

A Signer's signPsbtHex / signMessage may return either a string or a Promise<string>. Key-based signers (LocalSigner, HDSigner) sign synchronously, but a custom Signer that delegates to an external wallet — a browser extension or mobile wallet that prompts the user and never exposes its key — can sign asynchronously. Every workflow (openSellOrder, fillSwaps, delistSwap), send helper, and signAndFinalizeSellPrep awaits the result, so both forms work. getAddresses() stays synchronous (addresses are known up front).

Progress callbacks

Pass an optional second argument to openSellOrder, fillSwaps, or delistSwap to receive step-by-step progress events (useful for progress bars and status text):

await client.openSellOrder(params, {
  onProgress: ({ stepIndex, totalSteps, message, phase, step }) => {
    if (phase === "start" && totalSteps != null) {
      setProgress(stepIndex / totalSteps);
    }
    setStatus(message);
    console.log(step, phase, message);
  },
});

Each step emits phase: "start" before work begins and phase: "complete" when done. On failure, phase: "error" is emitted for the failing step before the error is re-thrown.

| Workflow | Steps (PSBT listings) | Steps (Kontor listings) | |----------|-----------------------|-------------------------| | openSellOrder | validateParamsrequestSellQuotesignPrepPsbt* → finalizePrepPsbt* → signSwapPsbtsignFeePsbt* → createSwap | validateParamspreflightKontorreserveKontorFeecomposeKontorOffercreateSwap | | fillSwaps | validateParamsrequestBuyQuotesignBuyerPsbtsubmitPurchase | validateParamspreflightKontorinspectKontorOfferacceptKontorOffersubmitPurchase | | delistSwap | startDelistsignDelistMessageconfirmDelist | preflightKontorrevokeKontorOfferstartDelistsignDelistMessageconfirmDelist |

* omitted when not applicable (no prep PSBT / no fee PSBT). For PSBT listings, totalSteps is null on the first openSellOrder events until the sell quote is received and the step plan is known; Kontor workflows know their step count up front.

React UI (optional)

Import from @unspendablelabs/horizon-market-client/react. Bundlers pick the web or React Native build automatically (react-native condition on the ./react export).

import {
  HorizonMarketProvider,
  LoginPanel,
  SellOrderForm,
  SwapConfirmation,
  SwapList,
} from "@unspendablelabs/horizon-market-client/react";

function App() {
  return (
    <HorizonMarketProvider
      network="mainnet"
      ordApiBaseUrl="https://ord.example.com"
      theme={{ colors: { primary: "#3b82f6" } }}
    >
      <LoginPanel getPrivateKey={yourWeb3AuthGetPrivateKey} />
      <SwapList getPrivateKey={yourWeb3AuthGetPrivateKey} />
      <SellOrderForm onSuccess={(swap) => console.log(swap.id)} />
    </HorizonMarketProvider>
  );
}

| Export | Description | |--------|-------------| | HorizonMarketProvider | Context: client, addresses, initialize / initializeWithMnemonic / initializeWithSigner / logout, theme | | useHorizonMarket, useTheme | Access provider state and resolved theme | | useLoginPanel, useAssets, useSellOrder, useSwapConfirmation, useSwapList | Headless hooks (build your own UI) | | useSellOrderForm | The packaged SellOrderForm's controller: grouped asset picker, gas-aware "Max", submit validity, result messaging. Wraps useSellOrder + useAssets — use it instead of them | | useSellReview, useBuyReview, useKontorPreflight | Review-step data layers (fee/cost preview, live price) and the Kontor chain gate that refuses a doomed flow before the user confirms | | useBtcBalance, useWithdraw, usePrices, useFeeEstimates | Headless wallet hooks (balances, withdraw flow, BTC/USD price, fee rates) | | useProfile, useProfileWallets | The account's Horizon Market profile: load, edit username / bio / visibility, upload an avatar, publish or hide each linked wallet. Idle until the wallet sign-in lands (the whole surface is session-gated) | | useToken, useTokenChart, useTokenActivity, useTokenSearch, useTokenList | One token's detail payload, price series and sales, a debounced cross-protocol autocomplete, and a paged browse grid. Protocol-blind: the same hooks serve all five asset types (see Tokens) | | useReportListing | Report a listing — or a token, resolved to one of its listings — for moderator review. canReport mirrors the wallet sign-in; a never-listed token ends in "unlisted" rather than an error. LISTING_REPORT_REASONS / LISTING_REPORT_REASON_LABELS are the form's options | | useAccountDeletion | Ask for the account to be deleted (App Store guideline 5.1.1(v)). Files a request a human acts on, not a delete. Names the connected wallet's addresses by default, and works with no wallet at all — the case it exists for | | useKontorFaucet | The signet KOR faucet — how a wallet with no KOR gets the gas every Kontor op needs. available is false off signet; plain HTTP, no @kontor/sdk behind it | | korCostForGas, maxListableKor, detachGasLimitFromBlob | Kontor gas pricing — pure arithmetic, no @kontor/sdk behind it, so a WASM-free bundle can still show what an op costs | | LoginPanel | Email + Web3Auth-style getPrivateKey flow | | SwapList | Browse, filter, buy, and delist swaps (orchestrates login + confirmation modals) | | SellOrderForm | Multi-step sell listing from the wallet's owned balances (pick asset, confirm, progress) | | SwapConfirmation | Buy or delist a swap with progress UI | | WithdrawForm, WalletBalances, WalletBalanceSummary | Wallet UI: send/withdraw any asset, full balances view, compact summary. WalletBalances takes a renderTokenAction(line) slot to add a host control to the XCP/KOR/ZELD rows (WalletTokenLine) | | WorkflowProgress, Modal | Standalone progress list and the shared overlay modal | | defaultTheme, resolveTheme | Theme helpers (plus themeToCssVars / webTokens on web) |

On web, the provider injects theme CSS variables (--hm-*) and falls back to shadcn/ui tokens when present. On React Native, pass styles overrides per component.

Connecting an external wallet

Besides initialize (raw key) and initializeWithMnemonic (phrase), the context exposes initializeWithSigner(signer) for wallets the SDK can't hold the key for — a browser extension or mobile wallet that signs through a prompt. Provide a Signer whose signPsbtHex / signMessage are asynchronous (see Asynchronous signers) and whose getAddresses() returns the connected addresses; the SDK never sees a private key. Pair it with autoSignIn={false} on the provider when you authenticate the API another way (e.g. a same-origin session cookie) so connecting doesn't trigger an extra wallet signature prompt — sign-in stays available on demand via the client's signInWithWallet().

Quick Start

import { HorizonMarketClient } from "@unspendablelabs/horizon-market-client";

const client = new HorizonMarketClient({
  privateKey: "your-private-key-hex",
  network: "mainnet",
});

// --- Open a sell order (counterparty, existing UTXO) ---
const { swap, created } = await client.openSellOrder({
  assetUtxoId: "abc123...64hex...:0",
  assetName: "RAREPEPE",
  assetQuantity: 1n,
  priceSats: 250_000,
  listingType: "counterparty",
});

// --- Open a sell order (counterparty, attach prep — no upfront UTXO needed) ---
const { swap: attachSwap } = await client.openSellOrder({
  assetName: "RAREPEPE",
  assetQuantity: 1n,
  priceSats: 250_000,
  listingType: "counterparty",
});

// --- Open a sell order (ZELD transfer prep — mainnet only) ---
const { swap: zeldSwap, created: zeldCreated } = await client.openSellOrder({
  listingType: "zeld",
  assetName: "ZELD",
  assetQuantity: 100_000_000n,
  priceSats: 250_000,
  // No assetUtxoId — server composes prep_psbt; SDK finalizes → zeld_payment
});

// --- Buy ---
const sales = await client.fillSwaps({
  swapIds: ["swap_abc", "swap_def"],
  buyerAddress: "bc1q...",
  satsPerVbyte: 5,
  detach: true,
});

// --- Delist ---
await client.delistSwap("swap_abc");

Kontor (KOR token + NFT)

Kontor assets use the same openSellOrder / fillSwaps / delistSwap methods — just pass listingType: "kontor". Unlike the PSBT asset types (where the server composes an unsigned PSBT and the client signs it), Kontor atomic swaps are composed, signed, and broadcast entirely client-side by the embedded @kontor/sdk. Your private key never leaves the client — only signed transactions, the offer blob, and public addresses are ever sent to the API.

Kontor is signet-only today, so construct the client with network: "testnet" (signet shares testnet address params) and kontorNetwork: "signet":

const client = new HorizonMarketClient({
  privateKey: "your-private-key-hex",
  network: "testnet",
  kontorNetwork: "signet",
});

// --- Sell KOR (fungible token) ---
const { swap } = await client.openSellOrder({
  listingType: "kontor",
  kontorAssetKind: "token",
  korAmount: "100.5",        // decimal string
  priceSats: 50_000,
});

// --- Sell a Kontor NFT ---
await client.openSellOrder({
  listingType: "kontor",
  kontorAssetKind: "nft",
  nftId: "my-nft-id",
  nftContractAddress: "[email protected]",
  priceSats: 250_000,
});

// --- Buy a Kontor swap (exactly one swapId) ---
await client.fillSwaps({ swapIds: ["swap_kontor_abc"] });

// --- Delist (revokes the on-chain offer, then BIP322-confirms) ---
await client.delistSwap("swap_kontor_abc");

Funding UTXOs. Kontor transactions are funded by your taproot UTXOs. By default the client auto-fetches your confirmed taproot UTXOs from Horizon (only your public address is sent). To supply them yourself — or use a dedicated funding address — pass fundingUtxos on sell, kontorFundingUtxos on buy, or fundingUtxos in delistSwap options (a KontorUtxoInput[] or a () => Promise<KontorUtxoInput[]> fetcher).

Pre-flight (preflightKontor). Every Kontor contract call is gas-metered: the node holds gas_limit × 1e-9 KOR from the op's payer before dispatching it, and drops the op when the payer can't cover that — leaving no result row anywhere, while the Bitcoin transaction carrying it still confirms. A listing whose asset was never escrowed, a purchase that pays the seller and delivers nothing, a delist that spends the escrow UTXO and strands the asset: all silent, all reported as success by every API. So each flow's first step reads chain state with view calls only — no signature, no transaction, nothing spent — and refuses to broadcast:

  • openSellOrder — the seller can pay the attach's gas, and actually holds the NFT / KOR being listed (KOR: the amount plus its own gas, which is held first out of the same balance). Runs before the fee quote, so a blocked listing never burns a listing credit.
  • fillSwaps — the buyer can pay the Sponsor's gas, and the listing's escrow really holds the advertised asset. IncomingOffer.inspect() only validates the offer blob's structure, so an unbacked listing is otherwise indistinguishable from a good one until the buyer has paid.
  • delistSwap — the seller can pay the detach's gas (read from the offer blob). The only one of the three whose failure is unrecoverable: the revoke spends the escrow, so it can never be retried.

The refusals are KontorInsufficientGasError (carries requiredKor / availableKor / operation), KontorAssetUnavailableError and KontorEscrowNotFundedError — all exported, all raised before signing, so the user can fund the account and retry with nothing lost. isKontorPreflightRefusal(err) tells those three apart from a real failure without importing the classes. An indexer failure during the check surfaces as an error, never as "you have no KOR". korCostForGas(gasLimit), maxListableKor(balance) and the KONTOR_*_GAS_LIMIT constants are exported for pricing this in your own UI.

Checking early. The same checks are available as ordinary reads, so a review screen can refuse before asking the user to confirm rather than after. They resolve with a verdict instead of throwing, and are read-only — view calls against a read-only session, so no funding UTXOs and no wallet signing prompt:

const verdict = await client.preflightKontorListing({
  kontorAssetKind: "token",
  korAmount: "100",
});
// { ok, error, balanceKor, requiredKor, gasLimit, signerId }
if (!verdict.ok) showBlocker(verdict.error.message); // else enable the Confirm button

await client.preflightKontorPurchase(swap); // an AtomicSwap, or its id
await client.preflightKontorDelist(swap);

ok: false carries one of the three refusals above — something the wallet can fix. A failure to check (unreachable indexer, Kontor not configured) throws instead: it is not a verdict, and reporting it as one would tell a funded user to go fund their account. balanceKor / requiredKor are filled in either way, so you can print the gas cost next to the balance you just read. The workflows call these very functions, so what a review screen reports and what the broadcast enforces cannot drift apart.

The packaged React screens already do this, on web and native alike — <BuyReview/>, <SellReview/> and <SwapConfirmation/>'s delist step disable their button and show the reason instead of letting the user commit to a flow that would be refused. Each exposes the verdict as preflight, and the hook behind them is exported for a custom UI:

import { useKontorPreflight, kontorPreflightNotice } from "…/react";

const preflight = useKontorPreflight({ flow: "purchase", swap });
const notice = kontorPreflightNotice(preflight); // { tone, text } | null

<button disabled={!preflight.canSubmit}>Buy</button>;

A failed check never blocks. A refusal disables the button; an indexer that didn't answer shows a note (tone: "warning") and leaves it enabled. Blocking there would strand a perfectly funded user behind a transient outage, and would buy nothing: the workflow runs the same check again before it signs or broadcasts anything.

Orphan protection. Kontor transactions are broadcast on-chain before the corresponding server-side record. If the recording POST fails after the broadcast, the workflows throw marker errors so you can recover without re-broadcasting:

  • openSellOrderKontorListingNotRecordedError carrying { offerBlob, createRequest } — retry the POST, or revoke to reclaim the escrowed asset
  • fillSwapsKontorPurchaseNotRecordedError carrying { swapId, txId, buyerAddress } — the offer is consumed; retry only the recording
  • delistSwapKontorDelistNotRecordedError carrying { swapId } — the revoke happened; re-run only startDelist → sign → confirmDelist

External wallets. Kontor also works through a connected browser-extension or mobile wallet (Xverse, Horizon Wallet, …), not just an in-process key. Connect one via initializeWithSigner(signer): when the Signer doesn't hold a key (no getKontorSigning) but its getAddresses() returns a Taproot address and its x-only public key ({ p2tr, xOnlyPubkey }), the SDK builds a wallet-backed Kontor Signing that signs each transaction through the wallet's signPsbtHex prompt — the key is never exposed. Two things the wallet path needs:

  • The xOnlyPubkey must be the wallet's internal taproot key (what wallets return on connect), not the tweaked output key decoded from a bc1p… address. The SDK re-derives the P2TR address from it and asserts it matches p2tr, so a wrong key or network fails immediately.
  • The wallet's signPsbtHex must honor the per-input sighash the SDK stamps into the PSBT (the seller's detach input is signed SINGLE | ANYONECANPAY) — i.e. pass the wallet's allowedSignHash through when a non-default sighash is present.

Key-holding signers (LocalSigner / HDSigner, built from privateKey or mnemonic) use their in-process key via getKontorSigning(chain). BLS registration (raw Schnorr-over-digest) is the only Kontor capability the external-wallet path can't do, and no marketplace flow needs it.

Locked asset UTXOs

Before listing, check which asset_utxo_id values are already locked in active listings for your seller address(es). This avoids double-listing or picking UTXOs that collide with fee inputs.

const locked = await client.getLockedAssetUtxoIds({
  sellerAddress: "bc1q...",
});
// { "txid64hex...:0": true, "another...:1": true }

if (locked["my-txid:0"]) {
  // UTXO is already in an open listing — pick another or delist first
}

GET /api/atomic-swaps/asset-utxo-id reports locks only; it does not discover wallet UTXOs.

API

Constructor

new HorizonMarketClient({
  privateKey?: string | Uint8Array,  // hex, with or without 0x (single key backs both addresses)
  mnemonic?: string,                 // BIP39 phrase — derived via HDSigner.fromMnemonic (Horizon Wallet convention: BIP84 segwit + BIP86 taproot)
  mnemonicOptions?: { account?, passphrase? }, // BIP32 account index + BIP39 passphrase for `mnemonic`
  signer?: Signer,                   // custom signer (hardware wallet, etc.)
  network?: "mainnet" | "testnet",   // default: "mainnet"
  baseUrl?: string,                  // default: "https://horizon.market"
  fetch?: typeof globalThis.fetch,   // injectable fetch (for tests / custom runtimes)
  sessionToken?: string,             // reuse a NextAuth session cookie (fee credits) across processes
  bearerToken?: string,              // reuse a bearer token from signInWithWallet (cross-origin friendly)
  kontorNetwork?: "signet",          // enable Kontor ops (signet-only today; requires network: "testnet")
  kontorIndexerUrl?: string,         // default: public signet indexer; set for self-hosting / browser CORS
  kontorNftContractAddress?: string, // NFT contract to enumerate owned Kontor NFTs (no cross-contract query)
  counterpartyApiBaseUrl?: string,   // owned-balance reads; default: "https://api.counterparty.io:4000" (mainnet only)
  zeldApiBaseUrl?: string,           // ZELD balance reads (own protocol); default: "https://api.zeldhash.com" (mainnet only)
})

Signer precedence when several are given: signer > privateKey > mnemonic.

Note the two mnemonic paths derive different keys: the constructor mnemonic option follows the Horizon Wallet convention (HDSigner — a BIP84 key for the SegWit address, a BIP86 key for the Taproot address, coin_type per network), while LocalSigner.fromMnemonic derives a single BIP86 key backing both addresses (the web3auth model).

Mnemonic & Keystore

Pure-JS helpers (no node:crypto, no WASM — usable in Node, the browser and React Native with the react-native-get-random-values polyfill):

  • generateMnemonic(strength?) — 128-bit (12 words) or 256-bit (24 words, default) BIP39 phrase
  • validateMnemonic(mnemonic) — wordlist + checksum check
  • mnemonicToPrivateKey(mnemonic, { path?, passphrase? }) — derive a raw secp256k1 key (hex)
  • LocalSigner.fromMnemonic(mnemonic, { network?, path?, passphrase? }) — single-key signer (one BIP86 key backs both addresses — web3auth model; the SegWit address will not match a standard BIP84 wallet)
  • HDSigner.fromMnemonic(mnemonic, { network?, account?, passphrase? }) — Horizon-Wallet-compatible two-key signer (BIP84 segwit + BIP86 taproot, coin_type per network); this is what the client mnemonic option uses
  • deriveHorizonWalletKeys, horizonWalletPath, coinTypeForNetwork, privateKeyToMnemonic — lower-level derivation helpers
  • DEFAULT_DERIVATION_PATH — BIP86 m/86'/0'/0'/0/0 (coin_type fixed to 0; network chosen at address time)
  • encryptKeystore(secret, password, opts?) / decryptKeystore(json, password) — scrypt + AES-256-GCM keystore blobs (string → string; you own storage)

See apps/cli for an end-to-end integration (encrypted 0600 keystore file, init / list / balances / sell / buy / send).

Owned-Balance Reads

Read the connected wallet's real holdings (used by SellOrderForm / useAssets):

  • getCounterpartyBalances(addresses) — XCP + Counterparty assets per address (mainnet; excludes ZELD)
  • getZeldBalances(addresses) — ZELD balance per address from the ZeldHash API (its own protocol; mainnet only)
  • getKontorHoldings() — KOR token balance + owned Kontor NFTs (signet; NFTs require kontorNftContractAddress). It degrades to empty holdings instead of throwing when it can't read at all, so check unavailable ("runtime" | "network" | "wallet-key", else null) before treating an empty result as "this wallet holds nothing"

Each of these is read independently and a failure is non-fatal, so an empty group is not evidence of an empty wallet. useAssets() publishes why, per source, as sources: Record<SourceKey, SourceState>{ status: "loading" }, { status: "ok" }, { status: "error", error }, or { status: "unread", reason } when this app never asks for that source at all (no ordApiBaseUrl, Kontor off this network). Only ok licenses a "you hold none of these" empty state; the built-in WalletBalances follows that rule. errors remains the failure-only slice (an unread source is not a failure), and isEmpty is true only once every source has been read successfully.

Send / Withdraw

Compose, sign, and broadcast a plain transfer of any supported asset type (BTC / Counterparty / ZELD / ordinal / KOR / Kontor NFT) — used by the WithdrawForm component and the CLI send command:

  • prepareSend(request, options?) — compose + sign; returns a PreparedSend with the exact feeSats and a broadcast() method
  • send(request, options?) — prepare + broadcast in one call, returns { txid }
  • Types: SendRequest (discriminated on kind), SendResult, PreparedSend; options.protectedUtxoIds keeps inscription UTXOs out of BTC funding

Authentication & Credits (optional)

Wallet sign-in (BIP322) for platform-fee credits. Anonymous use works fine — these only unlock fee waivers:

  • signInWithWallet(params) — bearer-token sign-in (WalletTokenSignIn); pass the token back via the bearerToken option
  • signInWithWalletCookie(params) — cookie-based variant for same-origin apps (see sessionToken)
  • getCredits()CreditBalance | null (null = signed out; throws on transient server errors)
  • getSession() / isAuthenticated() / signOut() — session introspection and teardown

Profiles

The /api/profiles/* surface behind horizon.market's profile page, for clients that build their own. Everything under me is session-gated — there is no on-chain signature to stand in for authentication here — so call signInWithWallet() first; the public reads need no session at all.

Own profile:

  • getMyProfile(options?)MyProfile (username, bio, visibility, avatar URL, credits, points)
  • updateMyProfile({ username?, bio?, isPublic? }, options?) — partial update; a real username and isPublic: true completes profile setup and grants the one-off profile-setup reward. Throws 409 when the name is taken
  • checkUsernameAvailability(username, options?) — case-insensitive; your own current name reports as available
  • getMyAvatarDataUrl(options?) — your avatar as a data: URL (null when unset). That endpoint is auth-gated and no-store, so its URL can't be used as an image source directly
  • uploadMyAvatar(image, options?) — multipart upload; a Blob/File or a React Native { uri, name, type } picker result. Resized to 512×512 PNG server-side, max 5 MB
  • listMyWallets(options?) / setWalletVisibility(address, isPublic, options?) — linked wallets and which are public (an idempotent set, not a toggle)
  • listMyAssets(page?, options?) / listMyLikedAssets(page?, options?) — issued and followed assets
  • followAsset(asset, options?) / unfollowAsset(asset, options?) — idempotent; keyed on the Counterparty asset name, not a subasset longname
  • getMyPoints(page?, options?) — balance + reward history (PointsSummary; pagination applies to the history only)

Public reads (no authentication; a private and an unknown username both answer null / 404, so existence is never leaked):

  • getPublicProfile(username, options?)PublicProfile | null
  • publicAvatarUrl(username) — cacheable avatar URL, usable directly as an image source
  • listPublicCuratedAssets(username, page?, options?) / listPublicLikedAssets(...)
  • listPublicProfileListings(username, page?, options?) / listPublicProfilePurchases(...) — swaps in the same shape listSwaps() returns

isPlaceholderUsername(username) tells the random UUID a fresh account is created with apart from a name the user actually picked. In React, useProfile() and useProfileWallets() wrap all of this (load, edit, avatar upload, wallet visibility) for a profile screen.

Listing reports

POST /api/reports — the "report this" every app that shows user-listed content needs (App Store guideline 1.2). Session-gated (signInWithWallet() first), so the reporter is identifiable; a replay from the same account is not an error.

  • reportListing({ atomicSwapId, reason, details? }, options?)ListingReport (duplicate: true on a replay). reason is one of LISTING_REPORT_REASONS; LISTING_REPORT_REASON_LABELS names each for a form
  • findReportableListingId(query, options?) — a report names a listing, the one key every asset type shares, but a token page knows only the asset. This resolves a ReportableListingQuery (assetName / kontorNftId / listingType / kontorAssetKind) to a swap id: an open offer, else a completed sale, else a delisted one — null when the token was never listed
  • reportableListingQueryFor(token) — that query, read off a TokenDetail's offers.atomicSwapsQuery

In React, useReportListing({ target }) wraps both — target is a swap id or the query — and the native example app puts a Report control under every token's name.

Account deletion

POST /api/account-deletion-requests — the in-app "delete my account" Apple requires (App Store guideline 5.1.1(v)). Not session-gated, deliberately: the option has to work for someone who can no longer sign in, so the request names the account by email and/or the addresses it connected and an admin matches it to a user before anything is deleted. Nothing is deleted by this call.

  • requestAccountDeletion({ email?, addresses?, message? }, options?)AccountDeletionRequest. At least one of email / addresses; duplicate: true when a request for this identity was already pending, and the standing one keeps its place in the queue
  • parseAccountDeletionAddresses(raw) — split a free-text address field (one per line, or comma/semicolon separated) into that list
  • ACCOUNT_DELETION_MESSAGE_MAX_LENGTH, ACCOUNT_DELETION_MAX_ADDRESSES — the server's limits

Sign in first when you can. The bearer token rides along, and a request whose identity the session owns is recorded as proven — which is what lets a real owner's request take over one a stranger filed for the same identity. Signing in as somebody else proves nothing, and the server checks.

In React, useAccountDeletion() wraps it: walletAddresses are the addresses it will name, canProveOwnership says whether the request will arrive proven, and an empty request lands in "invalid" rather than "error" — a field to fill in, not a button to press again. The native example app puts it under Account on the Settings tab, behind a confirm step.

Tokens

/api/tokens/* answers with one payload shape for all five asset types — Counterparty assets, ZELD, Ordinals inscriptions, the Kontor KOR token and Kontor NFTs — so a token screen is one component tree rather than five. Public, unauthenticated, GET-only.

A token is addressed by a TokenRef ({ protocol, id }); tokenApiPath() is the single place the five URL shapes live:

| Protocol | Endpoint | | --- | --- | | counterparty | /api/tokens/counterparty/{asset} (subasset longnames included) | | zeld | /api/tokens/ZELD | | ordinals | /api/tokens/ordinals/{inscription} | | kontor | /api/tokens/kontor/KOR — signet only | | kontor-nft | /api/tokens/kontor/nfts/{nft_id} — signet only |

  • getToken(ref, options?)TokenDetail | null. null is an ordinary answer: an unknown asset, or a Kontor token asked of a mainnet host
  • getTokenChart(ref, { range } | { from, to }, options?)TokenChart | null (null when the token has no series at all — a one-of-a-kind token). Pass one form or the other, never both
  • getTokenActivity(ref, { offset?, limit? }, options?) — completed sales, newest first
  • searchTokens({ query, limit?, protocols?, listedOnly? }, options?) — one autocomplete across all five types. No offset: five independently-ranked sources cannot be paged coherently, so raise limit (max 50) and read truncated
  • listTokens({ protocol, offset?, limit?, listedOnly? }, options?) — one page of one protocol's catalogue, in the same row shape search returns (TokenSummary; a search hit is that plus match). counterparty, ordinals and kontor-nft only — ZELD and KOR are single tokens, so their detail routes are the way to read them. null when the network doesn't serve the protocol. One protocol per call: three catalogues ordered by three unrelated things cannot be interleaved into a list that pages coherently. Read source (catalogue / recent_window / order_book) before promising depth, and the three page statuses before trusting what a row says: hydration (were the rows named?), offers (were they priced?) and artworkpartial there means the server ran out of time resolving pictures out of Counterparty asset descriptions, so some rows kept a placeholder that do have art. Nothing failed, and the next read is better illustrated, so it is a reason to offer a re-read and never a reason to show an error
  • tokenRefFromSwap(swap) — the token a listing from listSwaps() sells, or null when it names none. This is what makes an asset name in a swap list linkable

Two conventions worth knowing. Values are typed, not pre-formatted: stats and properties are ordered lists of { key, label, value } where value.type (sats, address, datetime, badge, …) says how to render it — so a new row appears with no client change, and formatting stays the client's. And every price is integer sats, per whole unit, while supply and an activity row's quantity are decimal strings (they are bigint at rest and exceed float precision).

capabilities / availableSections say which sub-resources a token has, so a tab bar is built from the response rather than from hard-coded protocol knowledge. For the open offers, hand offers.atomicSwapsQuery to listSwaps() (or useSwapList) — every key in it is one that endpoint parses, so the listing is exactly the set offers.count describes. Forward all of them: the query pins the token (asset_name / kontor_nft_id / kontor_asset_kind, listing_type) and the listing state (expired, exclude_pending, unattached, funded, delisted, filled), and dropping one is how a list drifts away from the count rendered above it. useSwapList takes each as a fixed pin with no setter — a URL-driven view remounts to change them, and the user-facing controls (sort, "Sold", "My swaps", pagination) keep working within the pin. listingType is a pin too, distinct from defaultListingType which only seeds the control: on a pinned feed the type belongs to the asset, so setListingType no-ops and listingTypePinned tells a renderer to hide the control (both packaged SwapList components already do). Facet counts do not honour any of them, so leave includeFacets off.

In React: useToken(ref), useTokenChart(ref), useTokenActivity(ref) and useTokenSearch(). All of them abort a request the moment it is superseded — a token switch, a range change, a keystroke, an unmount — rather than let it finish for a screen nobody is looking at, and an aborted read never surfaces as an error. useTokenSearch is debounced (250 ms) and reports degraded when a source or the offer aggregate failed, since a partial answer otherwise looks identical to a complete one. useTokenList({ protocol, listedOnly?, limit? }) is the browse counterpart: it pages a catalogue with loadMore(), re-reads from offset 0 when the feed's identity changes (so one catalogue's rows are never appended to another's), and separates notAvailable — this network doesn't serve this protocol — from an empty list, which is a real answer. It also keeps artworkPartial out of degraded: a page short of pictures and a page short of names are not the same event, and a grid that says "something went wrong" because the next read will be prettier is a worse grid. Paging is by offset over a list that moves, so appended rows are de-duplicated by canonicalId and the next page is read from pagedCount — what the server has sent — rather than from tokens.length, which a dropped duplicate leaves behind.

Creating a token

/api/creations/* is the write counterpart of the tokens API: one request shape composes a Counterparty issuance or an ordinal inscription, so a client ships one create screen rather than one per protocol. type: "kontor" is reserved and answers 501 on signet / 404 elsewhere.

// 1. Pin the artwork (session-gated, max 10 MB).
const media = await client.uploadCreationMedia(file, { thumbnail: true });

// 2 & 3. Quote → sign → broadcast, in one call.
const created = await client.createToken(
  {
    type: "counterparty",           // or "ordinals"
    name: "MYASSET",
    description: "A picture",
    image: media.ipfsUrl,           // always an ipfs:// URI
    attributes: { rarity: "rare" },
    satsPerVbyte: 5,
    options: { quantity: "1000", divisible: false, lock: true },
  },
  { onProgress: (event) => console.log(event.message) },
);

image is always an ipfs:// URI — that is what lets one field mean the same thing on both chains: Counterparty stores it inside a pinned JSON descriptor, an ordinal inscribes the bytes behind it. A gateway https://… URL is rejected.

A quote is a real, metered request. There is no side-effect-free preview here: composing pins a descriptor (Counterparty) or pulls up to 350 kB of media through a gateway (ordinals), behind a session gate. So take one quote per attempt — and when a UI shows the fees before signing, take that quote yourself and hand it back:

const quote = await client.requestCreationQuote({ /* … */ });   // show quote.totalCostSats
const created = await client.createToken({ /* same params */, quote });  // skips re-quoting
  • requestCreationQuote(params, options?) — session-gated. Answers { identifier, psbtBase64, inputsToSign, revealTxHex, estimatedFeeSats, totalCostSats }
  • submitCreation(params, options?) — unauthenticated, idempotent; psbt (hex or base64) xor txHex
  • uploadCreationMedia(file, { thumbnail? }) — session-gated multipart; a Blob/File or a React Native { uri, name, type }
  • createToken(params, options?) — the workflow over all three

Two things a caller has to get right:

Never retry createToken after a submit failure. It throws CreationNotBroadcastError, which carries the exact body to re-POST. Re-running the workflow composes a second transaction — and for an ordinal, the first commit's funds are then stranded forever, since its reveal was pre-signed with a key the server discarded at quote time.

try { await client.createToken(params); }
catch (err) {
  const retry = creationRetry(err);
  if (retry) await client.submitCreation(retry.submit);   // the ONLY safe recovery
}

retry.possiblyBroadcast says whether re-composing could produce a second on-chain transaction, and it is the flag to branch on — not retry.commitTxid, which is a txid scraped out of a prose error message and is there to be shown, not trusted. It clears only when the server answered 4xx, having rejected the submit before it touched a node; a 5xx, a timeout or a dropped connection all leave it set, because being wrong in that direction strands an ordinal's commit forever while being wrong in the other costs one idempotent replay.

totalCostSats is BTC only. Counterparty charges 0.5 XCP to register a named asset (0.25 for a subasset, free for a numeric A… name) on top of it, and a short balance fails at compose time. xcpNameFee(name) is that number, and it is owed by the funding address: Counterparty debits it from the issuance's source, so XCP held elsewhere in the same wallet cannot pay for it. randomNumericAssetName() draws a free A… name, which is what lets a wallet holding no XCP at all create one.

Local guards, so a typo costs a form hint rather than a pin: validateCounterpartyAssetName, validateCreationQuantity, validateCreationAttributes, isIpfsUri, isFundableCreationAddress, parentAssetOf, isNumericAssetName, plus the server's own limits (MAX_CREATION_ATTRIBUTES, MAX_CREATION_MEDIA_BYTES, MAX_INSCRIPTION_BYTES, CREATION_MEDIA_TYPES, …).

In React, useCreateToken() is the whole screen's data layer: form values with the ordinals rule enforced (quantity 1, indivisible, locked), attribute rows, media upload, validation, the quote-then-confirm step machine, progress events, the XCP balance check, and a retry() that replays the submit alone. The fee rate lives on the form rather than in the confirm step — with no preview endpoint, changing it in a modal would pin a fresh descriptor per twiddle. While awaitingReplay is set the run has one way out and goBack() refuses, so a screen should hide its Back and dismiss affordances there and leave Retry. See apps/native/app/create.tsx for a complete screen built on it.

Pass a retryStore. Refusing goBack() keeps the signed body away from the user; the store keeps it away from the process. It is three JSON methods (load/save/clear) over AsyncStorage, localStorage or a file, keyed by network and funding address — without one, an OS kill or a swipe out of the app switcher loses the only thing that can finish a broadcast creation, which for an ordinal is the permanent stranding the whole replay design exists to prevent. A held recovery is restored on mount, straight onto the failed step.

And a replay can be refused rather than merely fail: a node answering "transaction already in block chain" for one that is mined would otherwise leave a screen with no exit at all. replayRejected says the last replay came back 4xx, and once canAbandonReplay is set — a replay was tried, and failed — abandonReplay() drops the body and returns to the form. Offer pendingSubmitJson to be saved first: it is irreversible, and it is the only copy.

A quote is metered, so the hook spends one only when it has to: requestQuote() reuses a held quote (backing out of the confirm sheet and pressing Create again re-opens it rather than pinning a second identical descriptor), refuses outright while canQuote is false, and settles the XCP balance check on the spot instead of on its debounce timer — otherwise pressing Create quickly enough walks past the very guard that exists to stop a doomed compose.

Workflow Methods

  • openSellOrder(params, options?) — quote → sign → submit sell listing; returns { swap, created, transactions } (transactions = on-chain txs the listing broadcast)
  • fillSwaps(params, options?) — quote → sign → submit purchase
  • delistSwap(swapId, options?) — start → sign (BIP322) → confirm delist
  • createToken(params, options?) — quote → sign → broadcast a new Counterparty asset or ordinal inscription
  • previewKontorListingFee(address) — side-effect-free Kontor listing-fee preview

REST Helpers

All REST helpers accept an optional second argument { signal?: AbortSignal } for request cancellation.

  • listSwaps(params?, options?) — filter by listingType, collection, price range (priceMin / priceMax, sats), and more
  • getSwapFacets(params?, options?) — reactive facet counts (type / price bucket / collection) for a filter set
  • getSwap(id, options?)
  • getLockedAssetUtxoIds(params?, options?)
  • searchAssetNames(params?, options?)
  • getPendingPurchaseTxIds(swapId, address, options?)
  • requestSellQuote(params, options?)
  • requestBuyQuote(params, options?)
  • requestFeeQuote(params, options?)
  • createSwap(req, options?)
  • purchaseSwaps(params, options?)
  • startDelist(swapId, options?)
  • confirmDelist(requestId, signature, options?)

Example:

const controller = new AbortController();
const swaps = await client.listSwaps({ limit: 10 }, { signal: controller.signal });

Notes

  • Private key security: never share your private key; this SDK signs locally.
  • price is the net sats the seller receives. Buyers pay price + royalty.
  • Quote expiry: fee_payment_id expires in 30 minutes — sign and submit promptly (null when feeWaived).
  • ZELD listings: mainnet only. Sell from an existing UTXO (fee_payment), or omit assetUtxoId for transfer prep (finalize prep_psbtzeld_payment on create, or funding_tx_hex when fee is waived).
  • ZELD idempotency: transfer-prep creates (zeld_payment) may return HTTP 200 with created: false on replay, or 409 on conflict. Do not blindly retry counterparty/ordinal creates.
  • Buyer address: must be P2WPKH (bc1q… / tb1q…) for counterparty/zeld.
  • Ordinal buys: provide buyerTaprootAddress (receives the inscription) plus P2WPKH buyerAddress (funds the purchase).
  • Prep listings: attach-prep and zeld transfer-prep swaps may be funded: false until the prep tx confirms — poll getSwap before fillSwaps.

License

MIT