Releases: UnspendableLabs/Horizon-Market-Client
Releases · UnspendableLabs/Horizon-Market-Client
Release list
v0.2.7
[0.2.7] - 2026-07-31
A faucet button in the wallet's KOR row. A signet wallet holding no KOR is stuck: Kontor charges gas in KOR, an account with none has its operation dropped before execution, and buying KOR would itself cost gas. The pre-flights added in 0.2.5 report that deadlock accurately and leave the user with nowhere to go. This release ships the way out — the Portal's signet faucet, reachable from the wallet screen on web and mobile, next to the balance it fixes.
Added
useKontorFaucet()andclient.requestKontorFaucet()— ask the signet faucet for KOR. The hook is a small state machine (available,status,result,error,request(),reset()) whoserequest()never rejects, so a button'sonPressneeds no try/catch; the client method is the same call for the CLI and for scripts. It credits the connected wallet's taproot key (getAddresses().xOnlyPubkey— the faucet credits a key, not an address) and resolves with the faucet's commit + reveal txids. The KOR lands when the reveal confirms, about a block later, so neither one refreshes balances — the host does that when it dismisses its success UI.availableis the render condition, not a disabled state: it is false off Kontor signet (mainnet has no faucet, and a greyed-out button there would advertise free coins that don't exist) and false for a wallet exposing no taproot key. The client method refuses the same cases before making a request.- Endpoint defaults to
${baseUrl}/api/kontor-faucet— horizon.market's proxy in front of the Portal faucet, which is where a browser has to go anyway (the Portal sets no CORS headers of its own). Override with the newkontorFaucetUrlclient option / provider prop to hit{portal}/api/faucetdirectly from Node or React Native. - No
@kontor/sdkbehind any of it. It is a plainfetch, so it works in a web bundle built to keep the Kontor WebAssembly out and on a React Native build with no@kontor/sdk-nativelinked — the very builds where every other Kontor call fails, and where funding an account is what you'd want to do about it. The main entry exports the bare wire callrequestKontorFaucet(for a caller holding a recipient key and nothing else) alongsideKONTOR_FAUCET_AMOUNT_KOR;/reactexports the hook,KONTOR_FAUCET_AMOUNT_KORand theKontorFaucetResulttype.
renderTokenActiononWalletBalances(web + native) — where that button goes. The headline token rows (XCP / KOR / ZELD) ship a fixed set of controls — Deposit / Withdraw / Sell — so a host with one more to add had to choose between forking the component and putting its button somewhere else on the page, away from the balance it acts on. The prop is called once per headline token with that row's line and its return value is rendered at the start of the action group, ahead of the SDK's own buttons; returnnullfor the rows you don't extend. Both the horizon.market wallet page and the example mobile app now passline.symbol === "KOR" ? <KorFaucetAction /> : nulland nothing else.- The row shape is exported as
WalletTokenLine(symbol,amount,error,asset,sellAsset) so the callback can be typed and can branch on the same balance the row prints — including telling "the wallet holds none" from "the read failed", whichamount: nullpluserroralready keep apart. - Same prop, same position, same type name on both entries, so a host that renders both platforms writes the branch once.
- The row shape is exported as
- The example mobile app ships the faucet (
apps/native): a droplet button in the KOR row opening a confirm → requesting → txids modal, with the signet-explorer links and the same two Usermaven events the web wallet page reports (wallet_faucet_opened/wallet_faucet_completed), so the funnel is one chart across web and mobile.
v0.2.6
[0.2.6] - 2026-07-31
Added
useSellOrderForm()— the packaged sell form's controller, now public. An app rendering its own sell UI haduseSellOrder+useAssetsand nothing else, so it re-derived by hand every rule sitting between them: the picker's grouping/order/labels, whether the selected asset shows a quantity field, whether the form is submittable, what "Max" means, what to say when one balance source fails rather than comes back empty, and what the result step reports for a listing that broadcast one, two or no transactions. Each of those has a way of being subtly wrong — and one of them is wrong when re-derived naively: Max for KOR is not the balance. Escrowing KOR and paying theattach's gas draw on the same balance and the gas hold lands first, so "list everything" is always a listing whose own gas makes it unexecutable. The controller has priced that in since 0.2.5; hosts couldn't reach it. It also carries the selection reconciliation a hand-rolled form tends to miss: after a balance refresh the selected option is re-pointed at the fresh object (so Max and the balance cap use current numbers) or cleared when the wallet no longer holds it.- Exported from
/reactasuseSellOrderFormwithAssetGroup,SellTrackTx,SellResultViewandUseSellOrderFormResult. It wrapsuseSellOrderanduseAssetsand re-exposes both, so a host uses it instead of them, not alongside — one selection, one refresh cycle. - Same hook the packaged
SellOrderFormrenders on both platforms (it moved fromreact/internal/toreact/hooks/and was renamed fromuseSellOrderFormController; it was never exported, so nothing breaks). What a host builds and what the SDK ships now diverge only in markup.
- Exported from
- Kontor gas pricing re-exported from
/react:korCostForGas,maxListableKor,detachGasLimitFromBloband the three gas limits. They were reachable only from the main entry, which statically imports@kontor/sdk— so a React host wanting to print "gas ≈ 0.0001 KOR" next to a balance had to choose between duplicating the arithmetic and pulling the Kontor WASM backend into a bundle built to avoid it.kontor/gas.tshas no@kontor/sdkimport precisely so it can sit on this side of that line.
Fixed
- The React entries' freedom from
@kontor/sdkis now a test, not a manual check. 0.2.5 fixed the web bundle eagerly compiling the Kontor WebAssembly and verified it by inspection — but the property is one accidental re-export away from breaking (a helper pulled from a module that happens to neighbour Kontor code is enough), and nothing about the failure is visible in normal use: the entry just gets heavier, and Hermes builds without@kontor/sdk-nativelinked start crashing on import.react/bundle-purity.test.tswalks the static import graph of both entries from source and fails on any@kontor/*specifier that survives compilation — following relative imports (erroring on one it can't resolve, so the test can't pass by not looking), and deliberately ignoringimport type(erased) andimport()(the mechanism that keeps Kontor out of the entry). A companion assertion pins the other direction, because "reaches no Kontor backend" is satisfied just as well by a graph that reaches nothing: the entries must still statically reachkontor/gas.ts, and that file must still be free of@kontor/sdk. Moving the Max affordance's arithmetic behind a module that does pull the backend would keep the first assertion green while the React layer silently lost its ability to price gas at all — so the two are checked together, and with the same import walker, so both directions agree that a bareimport "@kontor/sdk"counts and an erasedimport typedoes not.
v0.2.5
[0.2.5] - 2026-07-31
Added
client.preflightKontorListing()/preflightKontorPurchase()/preflightKontorDelist()— the Kontor gate below, available as an ordinary read so a review screen can refuse before asking the user to confirm rather than after. They resolve with{ ok, error, balanceKor, requiredKor, gasLimit, signerId }instead of throwing, and are read-only:viewcalls against a read-only session, so no funding UTXOs and no wallet signing prompt — bound to the same identity that would sign (both derive fromgetAddresses().xOnlyPubkey), because a pre-flight that checked a different account than the one that pays would prove nothing.ok: falsecarries one of the three refusals — 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/requiredKorare filled in either way, so a review screen can print the gas cost next to the balance it just read.- The workflows call these very functions —
openSellOrder/fillSwaps/delistSwapthrowverdict.error— so what a review screen reported and what the broadcast enforces cannot drift apart. There is one check, with two ergonomics. isKontorPreflightRefusal(err)is exported for the throwing side too: a host catching from a workflow can tell a safe, nothing-happened refusal from a real failure without importing three classes toinstanceof.horizon sell/horizon buynow run it before their confirmation prompt, and print the gas cost alongside the fee review.
- React: every packaged review screen refuses a doomed Kontor flow before the user commits (web + native, one implementation each). The workflows already refuse to broadcast, so this changes when the user finds out: previously they confirmed, watched the progress modal, and read the refusal on the result screen. Now the button is disabled with the reason beside it, on all three flows and both platforms:
useBuyReview/<BuyReview/>— a Kontor buy has no composable quote to fail on, so the chain read is its equivalent early warning.canConfirmnow follows it (it was hard-codedtruefor Kontor).useSellReview/<SellReview/>— asked off the very params about to be submitted, and the one that saves the most:openSellOrderreserves the listing fee (and can burn a listing credit) before its own gate would find out. Folded intocanSign.useSwapConfirmation/<SwapConfirmation/>— gains an optionalswapoption, used to gate a Kontor delist. The most consequential of the three: a revoke spends the escrow UTXO, so a droppeddetachcan never be retried.- All three expose the verdict as
preflight({ loading, blockedReason, checkError, requiredKor, balanceKor, canSubmit, recheck }), anduseKontorPreflight/kontorPreflightNoticeare exported from/reactso an app rendering its own review UI can gate the same way, with the same wording. - A failed check never blocks. A refusal disables the button; an unreachable indexer shows a note and leaves it enabled — blocking there would strand a funded user behind a transient outage, and buy nothing, since the workflow re-runs the same check before it broadcasts anything.
Fixed
- No Kontor transaction is broadcast any more until chain state says it will actually execute. Every Kontor contract call is gas-metered: the node holds
gas_limit × gas_to_token_multiplierKOR from the op's payer before dispatching the call, and rejects the op when the payer can't cover it. That rejection happens ahead of execution, so the node writes no result row — the op leaves no trace in/results,GET /transactions/{txid}/inspectstill reports it asMaterializedwith a null result, and the SDK's poller (which synthesises its events from result rows) never sees it. The Bitcoin side settles regardless: the seller's attach reveal still creates its escrow output and still pays the listing fee; the buyer's swap reveal still pays the seller; a revoke still spends the escrow. On signet this produced a live listing whose NFT was never escrowed, and then a purchase where the buyer paid 3333 sats and received nothing — with every API surface reporting success. All three Kontor flows now gate on chain state first, withviewcalls only (no signature, no transaction, nothing spent), and every app on this SDK — web, native and the CLI — inherits the gate throughopenSellOrder/fillSwaps/delistSwap:openSellOrder(listingType: "kontor") checks that the seller can pay the attach's gas and actually holds the NFT / KOR being listed, before the fee quote is reserved (so a blocked listing never burns a listing credit) and before the attach reveal is broadcast.fillSwapson a Kontor swap checks that the buyer can pay theSponsor's gas and that the listing's escrow really holds the advertised asset —IncomingOffer.inspect()is a structural check on the offer blob and says nothing about chain state, so an unbacked listing was previously indistinguishable from a good one right up until the buyer had paid for it.delistSwapon a Kontor swap checks that the seller can pay thedetach's gas, priced from the limit baked into that offer blob rather than assumed. This is the flow whose failure is unrecoverable: a revoke spends the escrow UTXO, so a dropped detach leaves the asset credited to an outpoint that no longer exists andrevoke()can never be retried. It was previously ungated.- All three emit a new
preflightKontorprogress step (KontoropenSellOrder/fillSwaps/delistSwapare now 5 steps), and throwKontorInsufficientGasError,KontorAssetUnavailableErrororKontorEscrowNotFundedError— all exported, all carrying the amounts involved (requiredKor/availableKor) so a host can render "you need KOR" with a top-up affordance. Every one of them is raised before signing, so the user can fund the account and retry with nothing lost. - The gate reads the payer's holder — the signer-id resolved from the session's internal x-only key — and nothing else. Balance display also queries the bech32m-tweaked taproot output key, but that is a different Kontor identity with its own signer-id: the node resolves the payer from the commit envelope's internal key and
attachmoves the asset fromctx.signer(), so counting the tweaked key's holdings would have let a wallet clear the gate on KOR the node cannot spend — precisely the silent drop being fixed. - An unregistered wallet is answered as "holds nothing" without a chain read at all. Every credit path canonicalises its holder-ref to a signer-id row and creates the signer entry on the way, so "no signer row" implies "no holdings" — and a holder-ref
viewfor an unknown x-only key is a deterministic error inside the node, which would have turned the commonest way to hit this gate (a brand-new wallet with no KOR) into a cryptic failure instead of a gas error. - Listing KOR now requires the amount plus its own gas. The two come out of one balance and the hold lands first, so "list my whole balance" cleared both checks taken separately and still had its
attachdropped. The React sell form's Max button for KOR stops short of the gas for the same reason, and disappears when the balance can't cover even that. (It is arithmetic on the displayed balance, which sums every holder the wallet might use, so it is a good default rather than a verdict — the review step'spreflightis what answers authoritatively.) - Pricing the gas and checking the asset are one pair of reads, not two. The gas half already resolves the signer-id and the KOR balance, so a listing pre-flight hands both to the asset half instead of asking the indexer the same two questions again — halving its round-trips and removing a window where the two halves of one verdict could describe different moments.
- An indexer failure during the check surfaces as an error, never as "you have no KOR": the signer lookup distinguishes a 404 (unregistered ⇒ genuinely zero) from a failed request. The workflows pass the client's own
fetchinto it (KontorContextgained the field), so a host-suppliedfetch— a proxy, a React Native polyfill — is honoured here as it already is by every other indexer read; bypassing it would make the check fail to reach an indexer the rest of the SDK reaches fine, and a failed lookup throws, so it would block the very flow it protects. - A listing that doesn't record which asset its escrow holds is treated as unverifiable, not unbacked.
kontorAmount/kontorNftId/kontorContractAddressare each nullable onAtomicSwap— a pre-convention or third-party listing may name its escrow but not its contents — so the escrow half is skipped (as it already was for a missingassetUtxoId) and the gas half still runs. Treating it as a parameter error instead would have taken a listing that is buyable today and made it unbuyable behind a cryptic throw. A field that is present but malformed still throws: corrupted listing data is worth surfacing rather than silently disarming the check. onProgresscan no longer break a workflow. A throw from the host's progress callback used to propagate out of the step that had just succeeded — onacceptKontorOfferthat meant an already-broadcast swap reveal surfacing as a raw callback error instead ofKontorPurchaseNotRecordedError, losing the txid the recovery needs; on the new pre-flight step it would have let the Kontor session escape itstry/finallyunclosed. Progress reporting is telemetry, so it is now contained: a throwing listener is swallowed, the step's own error is never replaced by it, and later events still fire.- Also exported:
korCostForGas(gasLimit),detachGasLimitFromBlob(blob), `ma...
v0.2.4
[0.2.4] - 2026-07-30
Added
- React: buy / delist funnel observer callbacks on
useSwapList/<SwapList/>(web + native) —onLoginRequired,onBuyStarted,onDelistStartedonuseSwapList, andonBuySuccess,onBuyError,onDelistSuccess,onDelistErroron<SwapList/>. Previously a host could only observe the rawonSwapSelecttap (fires for both buy and sell) with no visibility into the login/confirm/result funnel — buy/delist results were consumed entirely inside the component. These are observers, not overrides: the built-in login modal, confirmation modal, and result screens still drive the UX unchanged — the callbacks only notify, each passed the fullAtomicSwap(not just the asset-poorPendingSale) so a host can build rich analytics and branch kontor-vs-multisig viaswap.listingType.onLoginRequired(swap)fires fromonItemActionwhen acting while logged out, before the login modal opens.onBuyStarted(swap)/onDelistStarted(swap)fire when the buy/delist confirmation modal opens — immediately, or after a successful login.onBuySuccess(swap, sales)/onBuyError(swap, error)andonDelistSuccess(swap)/onDelistError(swap, error)fire fromSwapList's existing confirmation wiring.
Fixed
- A failed balance read no longer masquerades as an empty wallet.
WalletBalances(web + native) rendered "No {group} holdings yet." whenever a tab had no options — including when that source's fetch had failed, sinceuseAssetsresolves a rejected source to an empty list and only records the reason inerrors, which the component never read. An unreachable indexer, a down API, or a wallet the SDK can't identify all produced a confident, wrong "you own nothing" with no way to tell. A failure is only one of three ways an empty group can lie, souseAssetsnow publishes the whole picture assources: Record<SourceKey, SourceState>—loading(the read hasn't settled; previously every tab claimed "no holdings" for the length of the first fetch),ok,error, orunread(this app never asks for that source: noordApiBaseUrl, Kontor off this network). EachOtherGroupcarries its source'sstate, and the sharedemptyGroupNotice()decides what both renderers show — onlyoklicenses the "No {group} holdings yet." claim and its deposit affordance. A failure also marks the tab (!) so it's visible from a non-active tab;unreadandloadingdon't, being nothing to alarm the user about.unreaddeliberately stays out oferrors: nothing went wrong and there is nothing to retry. isEmptyno longer counts a failed read as an empty wallet. It wasloadedOnce && !allAssets.length— true even when every source had failed, which is exactly the conflation the rest of this fix removes. It now also requires that no source failed, so the empty states driven by it are only ever shown for a wallet that was actually read.SellOrderForm's asset picker gets a distinct "Couldn't load your assets" placeholder for that case (the per-source reasons were already listed under it).- The headline balances stop reporting a failed read as
0. The same lie sat above the tabs: XCP / KOR / ZELD fell back to"0"whenever their source yielded no holdings, failure included.TokenLinenow carries anerror(XCP ← counterparty, ZELD ← zeld, KOR ← kontor, BTC ← the BTC read) and a nullamountwhen set; all four render sites (web + native × full list + dropdown summary) print—instead of a balance they could not read, with the reason in the tooltip / accessibility label — and spelled out under the BTC line, which has room for it. This also giveserrors.zelda consumer: ZELD has no other-holdings tab, so its failure was previously unreportable. getKontorHoldings()now reports why it read nothing. It deliberately degrades to empty holdings rather than throwing (a Kontor outage must not break the whole balances read), so its silent bail-outs — no@kontor/sdkbackend, a non-signet network, or a wallet exposing no taproot x-only key — were indistinguishable from an empty wallet and never reachederrors.kontorat all. The result gainsunavailable: KontorUnavailableReason | null("runtime" | "network" | "wallet-key"), whichuseAssetsturns into a user-facingerrors.kontor. Null both on success and when Kontor is simply not configured. Note: consumers that construct aKontorHoldings(test doubles) must add the field.- CLI:
horizon balancesstops printing0for a source it couldn't read. Same bug, same shape — every read was folded into an empty list on rejection. XCP / ZELD / KOR now print—when their read failed (KOR also whengetKontorHoldings()reportsunavailable), each failure is named under the table it explains, and--jsongains anerrorsobject (null per source on success) so scripts can tell "holds none" from "couldn't look". The BTC price feed is tracked as its own source (errors.price) — without it a down feed just dropped the(…)USD suffix, reading as "no price for you" — and an ord API that didn't answer is reported under its ownOrdinalsheading rather than printing nothing at all, which is what an inscription-free wallet also does. - The balances cache no longer persists a failed read — or re-fetches everything because of one. It stored holdings only, so a later mount seeding from it reported no errors and replayed the silent, wrong "you hold nothing" for the rest of the one-hour TTL. The stored payload is now
{ assets, stale }(cache key bumpedv1→v2, old entries read as a clean miss): the sources that answered are cached, the ones that failed are named, and the next mount paints the cached part immediately while re-fetching only the stale sources. Dropping the whole snapshot instead would have made one flaky source — the ord API, typically — cost a full re-read of the other three on every mount until it recovered. A topped-up entry keeps its original timestamp, so a source that keeps failing can't extend the others' TTL indefinitely.
v0.2.3
[0.2.3] - 2026-07-23
Added
- React: deposit / withdraw observer callbacks on
<WalletBalances/>(web + native) —onDeposit,onWithdraw, andonWithdrawComplete. Previously the only host-observable action wasonSellAsset; deposit and withdraw happened entirely inside the component, so a host could not track them. These three are observers, not overrides: the built-in deposit modal and withdraw form still drive the UX unchanged — the callbacks only notify.onDeposit(event: WalletDepositEvent)fires when a deposit address is opened ({ symbol, depositType, asset? }—assetset when opened from a specific holding).onWithdraw(event: WalletWithdrawEvent)fires when a withdraw flow is opened for a balance ({ target }, covering BTC + every asset kind).onWithdrawComplete(event: WalletWithdrawCompleteEvent)fires when a withdraw broadcasts successfully ({ target, txid }).- New exported types:
WalletDepositEvent,WalletWithdrawEvent,WalletWithdrawCompleteEvent. The shareduseWalletBalancesControllernow accepts an optional{ onDeposit, onWithdraw }and exposesopenWithdraw(target), so both renderers fire the callbacks identically.
v0.2.2
[0.2.2] - 2026-07-22
Added
useSwapList/SwapList: price-range and collection filters. New optionsdefaultPriceMin/defaultPriceMax(inclusive sat bounds) anddefaultCollection(a collection slug), surfaced as reactive statepriceMin/priceMax/collectionwith setterssetPriceRange(min, max)andsetCollection(slug). Both reset to the first page and re-querylistSwapswith the matchingpriceMin/priceMax/collectionparams (added to the client in 0.1.2); unset bounds are omitted from the query (sent asundefined, nevernull). PairsetPriceRangewith a price bucket's sat-bounds from the facets below.useSwapList/SwapList: opt-in reactive facet counts viaincludeFacets. When on, onegetSwapFacetsrequest (added in 0.1.2) runs per filter change — never one per option — using the exact same filters as the feed (minus sort/pagination), and the counts are exposed asfacets(pertype, per price bucket, percollection) andfacetsLoading. Stale responses are seq-guarded, the last-known counts stay visible while the next request is in flight (no flicker to empty), and a facets failure is swallowed — it never surfaces the mainerrorbanner. Powers a faceted filter sidebar with live, filter-aware counts.- New
@unspendablelabs/horizon-market-client/swapssubpath — a WASM-free entry that re-exports the SDK's pure swap-list logic: the display derivations (swapDisplayName,swapDisplayTitle,swapListItemView,swapMonogram,swapImageUrl,swapDisplayQuantity,swapDisplayPricePerUnit,pendingSwapTrackingTxid,formatQuantity), the list utilities (mergeSwapsById,sortSwaps,paginateSwaps,clampPage,getSellerAddresses,checkIsMySwap), and the list constants / sort presets / types (DEFAULT_LIMIT,FILTER_TABS,SORT_OPTIONS,SORT_OPTION_LABELS,SORT_MAP,SortOption,SwapListingType,SwapListOrderBy,SwapListOrder, thePENDING_ORDERS_*/MY_SWAPS_*caps). Unlike the main (.) and./reactbarrels — which statically import@kontor/sdkand eagerly compile WebAssembly on import — this entry has zero runtime dependency on@kontor/sdk,bitcoinjs-lib,@scure/*,ecpair, or React, so it imports cleanly in Node unit tests and SSR-reachable code. It lets a host build its own faceted swap browser on the exact pure logic the packaged<SwapList/>uses, without duplicating it.
Changed
- (internal, no consumer-facing change) The swap-list sort presets, list constants, and the
SortOption/SwapListingType/SwapListOrderBy/SwapListOrdertypes moved out of the ReactuseSwapListmodule into the WASM-freeswapListConstantsmodule (the one now published at./swaps), removing the circular import between them. They stay re-exported from the./reactentry, so existing imports (SORT_OPTIONS,SORT_OPTION_LABELS,SortOption, …) are unchanged.
v0.2.1
[0.2.1] - 2026-07-21
Added
- React: new
onSellAsset?: (asset: AssetOption) => voidprop on the web<WalletBalances/>— override the per-asset "Sell" action. When provided, clicking Sell (on the XCP / KOR / ZELD headline tokens and on every "other holdings" tile) calls the host callback with the pre-selected asset instead of opening the built-in inline<SellOrderForm/>modal — e.g. to navigate to a dedicated Sell screen. When omitted, the internal modal is used as before. Brings the web component to parity with the nativeWalletBalances, which already exposesonSellAsset. - Counterparty subasset long names are now captured and surfaced for display. Subassets list on-chain under a numeric
A…name; the human-readable long name (e.g.PEPENARDO.CARD) was previously fetched from the API but discarded. Now:CounterpartyBalancegainsassetLongname: string | null(read from the balances endpoint'sasset_info.asset_longname).AtomicSwapgainsassetLongname?: string | null(mapped from theasset_longnamefield the Horizon backend resolves onlistSwaps/getSwap).- The owned-asset
AssetOption(counterparty variant) gainsassetLongname?: string | null.
- React: every packaged display surface now shows
assetLongname ?? assetName, so subassets read as their long name instead ofA4950…— the swap-list tiles /<SwapList/>, the buy & sell review screens (<SwapConfirmation/>/<SellOrderForm/>), and<WalletBalances/>(headline + "other holdings" tiles, deposit / withdraw modals, and the placeholder monogram).assetNameremains the identifier used for image resolution and API calls; only the display label changed.
Changed
- Withdraw / send: the wallet signature is now requested at broadcast time, on confirm — not while preparing the review screen.
prepareSend(andprepareBtc/prepareCounterparty/prepareZeld/prepareOrdinal) now compose and fund the transaction but leave it unsigned;PreparedSend.broadcast()signs it (prompting the wallet) then publishes. The packaged<WalletBalances/>withdraw flow therefore shows its confirmation screen first and only pops the wallet when the user hits "Confirm & send".feeSatsis unchanged — it is computed from UTXO selection at compose time, so the review screen still shows the exact miner fee before any signature. Kontor sends (kor/kontor-nft) were already sign-on-broadcast.SendClient.send()/sendAsset()(the one-shotprepare().broadcast()helpers) are unaffected.
v0.2.0
[0.2.0] - 2026-07-20
Added
- Asynchronous signers:
Signer.signPsbtHex/signMessagemay now returnstringorPromise<string>, so a customSignercan delegate to an external wallet (browser extension / mobile) that signs through a prompt and never exposes its key. Every workflow (openSellOrder,fillSwaps,delistSwap), send helper, andsignAndFinalizeSellPrepawaits the result;LocalSigner/HDSignerare unchanged (still synchronous).getAddresses()stays synchronous. - Kontor now works with external wallets (browser extension / mobile — Xverse, Horizon Wallet, …), not just in-process key signers. When a
Signerdoesn't implementgetKontorSigningbut exposes a Taproot address and its x-only public key (getAddresses()→{ p2tr, xOnlyPubkey }), the SDK builds a wallet-backed KontorSigningthat delegates transaction signing tosignPsbtHexand message signing tosignMessage— soopenSellOrder/fillSwaps/delistSwapfor KOR & Kontor-NFT listings run through the connected wallet's signing prompt, key never exposed. The wallet's internal taproot x-only key is Kontor's identity (re-tweaked to the P2TR address, which is asserted to match the wallet's own so a wrong key/network fails loudly). BLS registration (raw Schnorr-over-digest) is the only Kontor capability unavailable this way, and no marketplace flow needs it. Adds@scure/btc-signer/@scure/baseas direct dependencies. - React:
HorizonMarketProvidercontext gainsinitializeWithSigner(signer)— connect from a host-suppliedSigner(external wallet) instead of a raw key or phrase; addresses come from the signer,exportMnemonic()returnsnull, andsessionSourcereports"external". - React: new
autoSignInprop onHorizonMarketProvider(defaulttrue) — setfalseto skip the automatic BIP322 wallet sign-in on connect (for hosts that authenticate another way, e.g. a same-origin session cookie, or an external signer whose message signing would pop the wallet). Sign-in stays available on demand viaclient.signInWithWallet(). - React: exported the sell-review data layer —
useSellReviewplusSellCost,UseSellReviewArgs,UseSellReviewResult,FeeOption, and theFEE_HINTS/FEE_LABELS/FEE_OPTIONSconstants. It powers the packaged<SellOrderForm/>confirm step (listing/attach/network cost breakdown, live fee-rate selection, fee waiver, Kontor listing + attach-miner fee estimates); exporting it lets a host render its own confirmation UI on the exact same data, the way apps already build onuseSellOrder. - React: exported the buy-review data layer —
useBuyReviewplusUseBuyReviewArgs/UseBuyReviewResult(itsFeeOptionand theFEE_HINTS/FEE_LABELS/FEE_OPTIONSconstants are already exported viauseSellReview). It powers the packaged<SwapConfirmation/>buy confirm step (price + royalty + buyer miner-fee breakdown, live fee-rate selection, Kontor estimates); exporting it lets a host render its own buy confirmation UI on the exact same data, symmetric withuseSellReview.
Changed
- BREAKING:
signAndFinalizeSellPrep(quote, signer, btcNetwork)is nowasyncand returnsPromise<SignedSellPrepResult | undefined>(was synchronous), so it can await asynchronous signers. Addawaitat call sites.
Fixed
- Native example app (iOS): fixed a launch crash on TestFlight and physical iPhones —
dyld: Library not loaded: @rpath/RNWorklets.framework/RNWorklets. Expo 57's precompiled native modules shipped a dynamicRNReanimated.frameworkthat links@rpath/RNWorklets.framework, but thatRNWorklets.frameworkwas not embedded in the device IPA, so dyld failed before JS started (the simulator build embedded it and hid the issue).react-native-reanimatedandreact-native-workletsare now forced to build from source viaexpo.autolinking.ios.buildFromSource; under the app's static linking they compile into static libs, so no dynamicRNWorklets.frameworkis produced and the@rpathload disappears. Both must be source-built together — building only worklets leaves the precompiled reanimated referencing the un-embedded framework.
v0.1.2
[0.1.2] - 2026-07-17
Added
- Price-range and collection filters on
listSwaps: newListSwapsParams.priceMin/priceMax(inclusive bounds in sats) andcollection(a collection slug the server expands to its asset names, so only Counterparty listings match) — combinable with every existing filter HorizonMarketClient.getSwapFacets(params?, options?)— one request returns reactive facet counts for a filter set (SwapFacets: listings pertype, per price bucket, and percollection); each dimension is counted excluding its own active selection so sibling options keep clickable, non-zero counts. Takes the same filter shape aslistSwapsminus pagination/sort (SwapFacetsParams); new typesSwapFacets,SwapFacetsParams,PriceBucketFacet(USD presets resolved to sat-bounds server-side), andCollectionFacet
v0.1.1
[0.1.1] - 2026-07-17
Added
- Pending-order surfacing on
listSwaps: newListSwapsParams.pendingAddress(a single address, or an array — one query prioritizes a wallet's in-progress orders across all its addresses) plus two newAtomicSwapfields,pendingRole("seller" | "buyer" | null) andpendingTxid(string | null), populated only for that address's pending rows andnulleverywhere else useSwapList/SwapList— opt-inincludePendingOrderssurfaces the connected wallet's in-progress orders (pending sell listings still settling on-chain + pending purchases whose buy tx is unconfirmed) aspendingOrders, self-polling until each tx confirms;trackPendingBuy(swap, txid)optimistically shows a just-made Kontor buy immediately, independent of the server'spending_addressdecoration- "Sold" feed on
useSwapList/SwapList—defaultShowSoldoption andshowSold/setShowSold/canShowSold: browse completed sales (the whole marketplace's, or narrowed to the wallet's own when combined with "My swaps"), as an independent dimension from the "My swaps" filter HorizonMarketClient.recordKontorPurchase(swapId, { buyerAddress, txId })— safe recovery that replays only the recording POST for a Kontor purchase whose swap reveal is already broadcast on-chain, never re-accepting (and re-broadcasting) the consumed offerkontorPurchaseRecovery(err)helper and theKontorPurchaseRecoverytype — extract{ swapId, txId, buyerAddress }from a caughtKontorPurchaseNotRecordedErrorwithout pulling the heavy@kontor/sdkbackend into the main bundle
Fixed
- Kontor KOR / NFT balance reads now resolve the wallet's registered Kontor
signer-id(cached per x-only pubkey) and sum balances across every plausible holder ref (signer-id + both x-only-pubkey forms) — a wallet whose assets were credited to its signer-id previously read as a zero balance - Kontor purchase recording failures now surface the real underlying cause instead of a generic error, and support a safe record-only retry