@unspendablelabs/horizon-market-client
v0.3.1
Published
TypeScript client for the Horizon Market Atomic Swap API
Readme
@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-clientFor 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-clipboardCLI
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 / sendQuote → 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 | validateParams → requestSellQuote → signPrepPsbt* → finalizePrepPsbt* → signSwapPsbt → signFeePsbt* → createSwap | validateParams → preflightKontor → reserveKontorFee → composeKontorOffer → createSwap |
| fillSwaps | validateParams → requestBuyQuote → signBuyerPsbt → submitPurchase | validateParams → preflightKontor → inspectKontorOffer → acceptKontorOffer → submitPurchase |
| delistSwap | startDelist → signDelistMessage → confirmDelist | preflightKontor → revokeKontorOffer → startDelist → signDelistMessage → confirmDelist |
* 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 theSponsor'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 thedetach'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:
openSellOrder→KontorListingNotRecordedErrorcarrying{ offerBlob, createRequest }— retry the POST, or revoke to reclaim the escrowed assetfillSwaps→KontorPurchaseNotRecordedErrorcarrying{ swapId, txId, buyerAddress }— the offer is consumed; retry only the recordingdelistSwap→KontorDelistNotRecordedErrorcarrying{ swapId }— the revoke happened; re-run onlystartDelist→ 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
xOnlyPubkeymust be the wallet's internal taproot key (what wallets return on connect), not the tweaked output key decoded from abc1p…address. The SDK re-derives the P2TR address from it and asserts it matchesp2tr, so a wrong key or network fails immediately. - The wallet's
signPsbtHexmust honor the per-input sighash the SDK stamps into the PSBT (the seller's detach input is signedSINGLE | ANYONECANPAY) — i.e. pass the wallet'sallowedSignHashthrough when a non-default sighash is present.
Key-holding signers (
LocalSigner/HDSigner, built fromprivateKeyormnemonic) use their in-process key viagetKontorSigning(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 phrasevalidateMnemonic(mnemonic)— wordlist + checksum checkmnemonicToPrivateKey(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_typeper network); this is what the clientmnemonicoption usesderiveHorizonWalletKeys,horizonWalletPath,coinTypeForNetwork,privateKeyToMnemonic— lower-level derivation helpersDEFAULT_DERIVATION_PATH— BIP86m/86'/0'/0'/0/0(coin_typefixed 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 requirekontorNftContractAddress). It degrades to empty holdings instead of throwing when it can't read at all, so checkunavailable("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 aPreparedSendwith the exactfeeSatsand abroadcast()methodsend(request, options?)— prepare + broadcast in one call, returns{ txid }- Types:
SendRequest(discriminated onkind),SendResult,PreparedSend;options.protectedUtxoIdskeeps 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 thebearerTokenoptionsignInWithWalletCookie(params)— cookie-based variant for same-origin apps (seesessionToken)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 andisPublic: truecompletes profile setup and grants the one-offprofile-setupreward. Throws409when the name is takencheckUsernameAvailability(username, options?)— case-insensitive; your own current name reports as availablegetMyAvatarDataUrl(options?)— your avatar as adata:URL (nullwhen unset). That endpoint is auth-gated andno-store, so its URL can't be used as an image source directlyuploadMyAvatar(image, options?)— multipart upload; aBlob/Fileor a React Native{ uri, name, type }picker result. Resized to 512×512 PNG server-side, max 5 MBlistMyWallets(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 assetsfollowAsset(asset, options?)/unfollowAsset(asset, options?)— idempotent; keyed on the Counterparty asset name, not a subasset longnamegetMyPoints(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 | nullpublicAvatarUrl(username)— cacheable avatar URL, usable directly as an image sourcelistPublicCuratedAssets(username, page?, options?)/listPublicLikedAssets(...)listPublicProfileListings(username, page?, options?)/listPublicProfilePurchases(...)— swaps in the same shapelistSwaps()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: trueon a replay).reasonis one ofLISTING_REPORT_REASONS;LISTING_REPORT_REASON_LABELSnames each for a formfindReportableListingId(query, options?)— a report names a listing, the one key every asset type shares, but a token page knows only the asset. This resolves aReportableListingQuery(assetName/kontorNftId/listingType/kontorAssetKind) to a swap id: an open offer, else a completed sale, else a delisted one —nullwhen the token was never listedreportableListingQueryFor(token)— that query, read off aTokenDetail'soffers.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 ofemail/addresses;duplicate: truewhen a request for this identity was already pending, and the standing one keeps its place in the queueparseAccountDeletionAddresses(raw)— split a free-text address field (one per line, or comma/semicolon separated) into that listACCOUNT_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.nullis an ordinary answer: an unknown asset, or a Kontor token asked of a mainnet hostgetTokenChart(ref, { range } | { from, to }, options?)—TokenChart | null(nullwhen the token has no series at all — a one-of-a-kind token). Pass one form or the other, never bothgetTokenActivity(ref, { offset?, limit? }, options?)— completed sales, newest firstsearchTokens({ query, limit?, protocols?, listedOnly? }, options?)— one autocomplete across all five types. Nooffset: five independently-ranked sources cannot be paged coherently, so raiselimit(max 50) and readtruncatedlistTokens({ 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 plusmatch).counterparty,ordinalsandkontor-nftonly — ZELD and KOR are single tokens, so their detail routes are the way to read them.nullwhen 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. Readsource(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?) andartwork—partialthere 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 errortokenRefFromSwap(swap)— the token a listing fromlistSwaps()sells, ornullwhen 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-quotingrequestCreationQuote(params, options?)— session-gated. Answers{ identifier, psbtBase64, inputsToSign, revealTxHex, estimatedFeeSats, totalCostSats }submitCreation(params, options?)— unauthenticated, idempotent;psbt(hex or base64) xortxHexuploadCreationMedia(file, { thumbnail? })— session-gated multipart; aBlob/Fileor 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 purchasedelistSwap(swapId, options?)— start → sign (BIP322) → confirm delistcreateToken(params, options?)— quote → sign → broadcast a new Counterparty asset or ordinal inscriptionpreviewKontorListingFee(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 bylistingType,collection, price range (priceMin/priceMax, sats), and moregetSwapFacets(params?, options?)— reactive facet counts (type / price bucket / collection) for a filter setgetSwap(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.
priceis the net sats the seller receives. Buyers payprice + royalty.- Quote expiry:
fee_payment_idexpires in 30 minutes — sign and submit promptly (null whenfeeWaived). - ZELD listings: mainnet only. Sell from an existing UTXO (
fee_payment), or omitassetUtxoIdfor transfer prep (finalizeprep_psbt→zeld_paymenton create, orfunding_tx_hexwhen fee is waived). - ZELD idempotency: transfer-prep creates (
zeld_payment) may return HTTP 200 withcreated: falseon 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 P2WPKHbuyerAddress(funds the purchase). - Prep listings: attach-prep and zeld transfer-prep swaps may be
funded: falseuntil the prep tx confirms — pollgetSwapbeforefillSwaps.
License
MIT
