Skip to content

v0.2.5

Choose a tag to compare

@ouziel-slama ouziel-slama released this 31 Jul 14:01
89d263c

[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: view calls against a read-only session, so no funding UTXOs and no wallet signing prompt — bound to the same identity that would sign (both derive from getAddresses().xOnlyPubkey), because a pre-flight that checked a different account than the one that pays would prove nothing.
    • ok: false carries 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 / requiredKor are 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 / delistSwap throw verdict.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 to instanceof.
    • horizon sell / horizon buy now 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. canConfirm now follows it (it was hard-coded true for Kontor).
    • useSellReview / <SellReview/> — asked off the very params about to be submitted, and the one that saves the most: openSellOrder reserves the listing fee (and can burn a listing credit) before its own gate would find out. Folded into canSign.
    • useSwapConfirmation / <SwapConfirmation/> — gains an optional swap option, used to gate a Kontor delist. The most consequential of the three: a revoke spends the escrow UTXO, so a dropped detach can never be retried.
    • All three expose the verdict as preflight ({ loading, blockedReason, checkError, requiredKor, balanceKor, canSubmit, recheck }), and useKontorPreflight / kontorPreflightNotice are exported from /react so 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_multiplier KOR 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}/inspect still reports it as Materialized with 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, with view calls only (no signature, no transaction, nothing spent), and every app on this SDK — web, native and the CLI — inherits the gate through openSellOrder / 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.
    • fillSwaps on a Kontor swap checks that the buyer can pay the Sponsor's gas and that the listing's escrow really holds the advertised assetIncomingOffer.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.
    • delistSwap on a Kontor swap checks that the seller can pay the detach'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 and revoke() can never be retried. It was previously ungated.
    • All three emit a new preflightKontor progress step (Kontor openSellOrder / fillSwaps / delistSwap are now 5 steps), and throw KontorInsufficientGasError, KontorAssetUnavailableError or KontorEscrowNotFundedError — 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 attach moves the asset from ctx.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 view for 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 attach dropped. 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's preflight is 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 fetch into it (KontorContext gained the field), so a host-supplied fetch — 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 / kontorContractAddress are each nullable on AtomicSwap — 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 missing assetUtxoId) 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.
    • onProgress can no longer break a workflow. A throw from the host's progress callback used to propagate out of the step that had just succeeded — on acceptKontorOffer that meant an already-broadcast swap reveal surfacing as a raw callback error instead of KontorPurchaseNotRecordedError, losing the txid the recovery needs; on the new pre-flight step it would have let the Kontor session escape its try/finally unclosed. 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), maxListableKor(balance), KONTOR_ATTACH_GAS_LIMIT, KONTOR_ACCEPT_GAS_LIMIT, KONTOR_DETACH_GAS_LIMIT and the KontorGasOperation type. The pricing half lives in kontor/gas.ts, which imports no @kontor/sdk — so the React layer can size a Max button from it without pulling the Kontor WASM backend into a bundle that must not load it.
  • The web ./react bundle stopped eagerly loading the Kontor WASM backend. The client reaches every Kontor module through a dynamic import() precisely so @kontor/sdk never evaluates at startup — but the web React entry was built with splitting: false, so esbuild inlined those modules and hoisted their top-level import … from "@kontor/sdk" into the entry. Importing /react at all therefore compiled the Kontor WebAssembly, in apps that never touch Kontor. The native entry already sets splitting: true for exactly this reason (there the eager load crashes Hermes rather than merely costing); the web entry now does too. Verified: nothing reachable from dist/react/index.js's static import graph references @kontor/sdk, and the entry is ~19% smaller. This is also what makes kontor/gas.ts's no-@kontor/sdk rule pay off on web, rather than being undone by the bundler.
  • CLI: --json now carries a code for the errors a script has to branch on. Every SDK failure was emitted as { error: { message } }, so telling "fund the account and retry" apart from "the chain already moved, do NOT retry" meant grepping English prose. The SDK's Kontor errors are now mapped to stable codes — KONTOR_INSUFFICIENT_GAS, KONTOR_ASSET_UNAVAILABLE, KONTOR_ESCROW_NOT_FUNDED, KONTOR_LISTING_NOT_RECORDED, KONTOR_PURCHASE_NOT_RECORDED, KONTOR_DELIST_NOT_RECORDED, KONTOR_UNAVAILABLE, ZELD_TOO_MANY_UTXOS — alongside the CLI's own.