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

@arkade-os/solver-discovery

v0.3.0

Published

Portable ESM client for discovering solver price feeds from Arkade solver registries (browser / Node / Expo).

Readme

@arkade-os/solver-discovery

A tiny, portable ESM client for the consumer (maker) side of the Arkade Market Discovery Protocol: discover the price feeds solvers advertise, rank markets, convert amounts, and compute the wantAmount for an offer.

  • Runs everywhere — browser, Node, and Expo / React Native. The root entrypoint's only runtime dependencies are the pure-JS @noble/curves and @noble/hashes, for card signatures; fetch is the global one (and it's injectable).
  • No eval / new Function — validation is hand-rolled, so it works under a strict CSP and on Hermes (React Native), where JSON-Schema engines like Ajv fail.
  • Exact math — prices are BigInt rationals and amounts are BigInt, so no float error ever touches an amount.

Install

npm install @arkade-os/solver-discovery

Quick start

import { discover, listMarkets, bestMarket, quoteOffer } from "@arkade-os/solver-discovery";

// 1. Fetch + merge the registries you follow (plus any pinned local cards).
//    With no `registries`, discover() follows the published index for `network`
//    (default "bitcoin") — the default lives in this library, so its URLs can
//    change in a release without you shipping a client-side table. Pass `[]` to
//    opt out, or your own URLs to override the default rather than merge it.
const { markets, warnings } = await discover({});
if (warnings.length) console.warn(warnings);

// 2. List pairs for UI selection, then pick the best market for one pair.
//    `solvable` counts how many markets can pay out each side — don't offer a
//    direction whose receive side is at 0.
console.log(listMarkets(markets).map((p) => `${p.base_asset.ticker}/${p.quote_asset.ticker} base:${p.solvable.base} quote:${p.solvable.quote}`));
const market = bestMarket(markets, {
  baseId: "arkade:bitcoin/slip44:0",
  quoteId: "arkade:bitcoin/asset:47004bf4a5fbdb2221f708030528de68ea28f5980044e546b7bb5a352457d1f30000",
  wantSide: "quote", // we give base and receive quote; skips markets that can't pay out quote
});
if (!market) throw new Error("no market solves this side of the pair");

// 3. Quote an offer — fetches the advertised feed and returns a ready plan.
const plan = await quoteOffer(market, { give: "base", giveAmount: "0.01" }); // 0.01 BTC
console.log(`${plan.deposit.display} ${plan.deposit.asset.ticker}`
  + ` -> ${plan.receive.display} ${plan.receive.asset.ticker}`);
// plan.receive.atomic is the wantAmount to request; then createOffer(...) as usual.
if (!plan.limits.withinLimits) console.warn("amount is outside the market's size limits");

Corridor markets

A market side may settle on another rail — Lightning (bolt11) or an L1 output (bitcoin), besides the arkade default — and that corridor is named as part of the side's id, a CAIP-19-shaped asset id (arkade:bitcoin/ slip44:0, bolt11:bitcoin/slip44:0, …), not a separate field. Market identity is simply base_asset.id + quote_asset.id, so a Lightning BTC market and an onchain BTC market never collapse into one group. Selection takes the whole id, corridor included:

Upgrading from 0.1.x: corridor entries have no feed fields, and 0.1.x quoteOffer throws on them (price_feed was assumed present; its type is now string | undefined). Upgrade before following any registry that lists corridor markets — a registry should not merge its first corridor card until its clients have.

Upgrading from a pre-bundling client (before this rewrite): pair, base_corridor, and quote_corridor are gone from solver cards, and SelectOptions.baseCorridor/quoteCorridor are removed along with them. Pass the fully-qualified id (e.g. "bolt11:bitcoin/slip44:0", not "btc" + quoteCorridor: "lightning") to baseId/quoteId instead.

Published indexes still carry all three for one release, so a v0 client keeps working across the cutover. For the same reason an index's id is down-projected to the v0 grammar ("btc" or a 68-hex AssetId) and the CAIP-19 identity travels in caip19_id beside it — read it via assetIdOf, and pass CAIP-19 to baseId/quoteId as above, which matches either spelling. Every one of these fields disappears when the window closes; none of them exists on a card.

const lnMarket = bestMarket(markets, {
  baseId: "arkade:bitcoin/slip44:0",
  quoteId: "bolt11:bitcoin/slip44:0", // the corridor is part of the id; omit it and you select the arkade leg
  wantSide: "quote",
});

A same-asset corridor market carries no feed — its price is identically 1 and fee_bps plus fee_flat are the whole price. quoteOffer / planOffer handle that transparently (nothing is fetched), but the plan is a pre-quote estimate: the binding amounts come from the solver's RFQ quote, requested via the market's discovery_pubkey over its transports (both present on every corridor entry). The RFQ exchange itself is out of this package's scope.

Amount conversion (Arkade Assets)

Each asset carries a decimals field (8 for BTC and most Arkade assets). Conversion between human and atomic units is exact:

import { toAtomic, fromAtomic } from "@arkade-os/solver-discovery";

toAtomic("1.5", 8);          // => 150000000n
fromAtomic(150000000n, 8);   // => "1.5"
toAtomic("1.123456789", 8);  // throws: more precision than 8 decimals allow

Pricing math always stays in atomic units; quoteOffer() does the human⇄atomic conversion for you using each side's decimals.

Price feed responses

Every market declares both the feed URL and how to read its response:

{
  "price_feed": "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
  "price_feed_schema": { "type": "json", "price_path": "/bitcoin/usd" }
}

price_path is an RFC 6901 JSON Pointer to a JSON number or numeric string. Common examples: Binance ticker price uses /price; a bare JSON number uses the empty pointer "". The client does not scan unknown response shapes.

Pin a local card

Users can pin a solver card directly (a raw card, validated against the card schema), participating in the merge like any registry entry. Omit registries to also get the published index for the network, or pass registries: [] to pin only and follow no registry at all:

const { markets } = await discover({
  localCards: [{ card: pastedCardJson }],
});

const pinnedOnly = await discover({
  registries: [],
  localCards: [{ card: pastedCardJson }],
});

Transport-delivered cards

A solver may publish its signed card on its own transports (nostr kind SOLVER_CARD_KIND, d = the network) — a registry pointer lists only its identity. Pass a transportCards fetcher and discover() asks it once for the latest card of every solver an index's solvers lists (plus pinned cards with a key and a nostr transport). The package opens no sockets itself:

const { markets } = await discover({
  transportCards: {
    // Latest event per author for this network; verify each EVENT signature here.
    fetch: async (solvers, network) => myRelayQuery(solvers, network), // => [{ pubkey, createdAt, card }]
  },
});

A card counts only from a listed key, under the listing's name, passing every registry rule, with a valid sig (verifyCardSig), and dated within MAX_CARD_AGE_SECONDS of now either way. It then replaces that solver's listed markets, which keep the listing's source. Anything else is a warning, never a throw.

A registry index older than DEFAULT_MAX_AGE_SECONDS (7 days) is stale. Its own markets are held out with a warning, but the solvers it lists still resolve over their transports, so a pointer-listed solver with a fresh card keeps trading. Past STALE_LISTING_TRUST_FACTOR (4) max-ages the index vouches for no one and its solvers stop resolving too, so a registry's removal of a solver eventually wins. Local pins are unaffected.

Expo / React Native

Works out of the box — Hermes ships fetch, AbortController, and BigInt. If you target an older runtime without global fetch, inject one:

import { discover } from "@arkade-os/solver-discovery";
await discover({ registries, fetchImpl: myFetch });

React

The optional React entrypoint keeps a two-input quote form synchronized. It fetches the market feed and recalculates the other field whenever the user edits the given or wanted amount:

import { useOfferQuote } from "@arkade-os/solver-discovery/react";

function QuoteForm({ market }) {
  const quote = useOfferQuote(market, { give: "base" });

  if (quote.solvable === false) return <p>This market cannot pay out the side you want.</p>;
  return (
    <>
      <input value={quote.giveAmount} onChange={(e) => quote.setGiveAmount(e.target.value)} />
      <input value={quote.wantAmount} onChange={(e) => quote.setWantAmount(e.target.value)} />
      <button disabled={!quote.plan?.limits.withinLimits}>Create offer</button>
    </>
  );
}

useOfferQuote(market, opts) returns the active input, both display amounts, the latest OfferPlan, loading/error state, and the give/want setters. With give: "base" the give field is the base amount and the want field the quote amount; swap them for give: "quote". quote.solvable says whether the market declares limits for — can pay out — the side received under give (null while no market is selected), and quote.plan?.receive.atomic is the wantAmount to request.

The package does not install React for you: importing @arkade-os/solver-discovery/react requires React in the app, while the root package entrypoint does not import React.

Core API

| Export | Purpose | |---|---| | discover(opts) | Fetch + merge + dedupe + rank markets across registries and local cards. Defaults to network: "bitcoin". With no registries, follows that network's published index (an internal default); registries: [] follows none; a non-empty list overrides the default. Registry failures are isolated. transportCards resolves solvers' signed cards from their transports. | | verifyCardSig(card) | Whether the card's sig is its discovery_pubkey's BIP340 signature over the canonical JSON. | | defaultRegistryUrls(network) / registryIndexUrl(network) / REGISTRY_INDEX_URLS | The published index URL(s) for a network — the default discover() reads, exported so callers can surface or override it instead of hand-copying it. | | fetchIndex(url, opts) | Fetch + validate a single per-network index (never throws). Defaults to network: "bitcoin". | | listMarkets(markets) | List available id pairs, how many solver candidates each pair has, and how many can pay out each side (solvable.base / solvable.quote). | | selectMarkets(markets, {baseId, quoteId, wantSide?, wantAmount?}) / bestMarket(..., {cursor?}) | Filter to one id pair — and optionally to markets that can pay out wantSide, sized by wantAmount on that side — keeping the ranking. cursor: 1 selects the second-ranked market. | | sideLimits(market, side) | The side's { min, max } bounds as exact bigints, or null when the solver cannot pay that side out — disabled (max = "0") or carrying malformed/validation-rejected bounds. The single per-side solvability + size-bound primitive. | | quoteOffer(market, {give, giveAmount \| wantAmount, safetyBps?}) | Fetch the feed and build a full OfferPlan (human in/out). | | planOffer({market, give, giveAmount \| wantAmount, feedValue, safetyBps?}) | Same, from an already-fetched feed value (pure/sync). | | fetchFeedValue(url, schema, opts?) | Fetch a raw feed and extract the numeric price using the market's price_feed_schema. | | deriveAtomicPrice / computeWantAmount | Pure pricing primitives (exact rationals / BigInt). | | toAtomic / fromAtomic / displayPriceString | Decimals-aware conversion. | | validateCard / validateIndex | Dependency-free, eval-free schema validation. |

give: "base" deposits the base asset and receives the quote; give: "quote" is the reverse (priced with 1/P). Pass exactly one of giveAmount or wantAmount: giveAmount fixes the deposit and computes the requested receive amount, while wantAmount fixes the requested receive amount and computes the minimum deposit. Either takes a display string/number, or a bigint to stay in atomic units. safetyBps defaults to 50 — the cushion that absorbs feed movement between funding and fill.

Markets carry size limits for both sides (min|max_base_amount, min|max_quote_amount) as decimal strings of atomic units — exact past 2^53, where JSON numbers silently round — and max = "0" disables a side: the solver cannot pay it out, so a market with zeroed base bounds only serves give: "base". Plans check the received side: plan.limits.min === null means the market cannot pay that side at all, and plan.limits.withinLimits says whether the received amount sits inside [min, max].

Roadmap

Chained (multi-hop) offers — not yet supported: bestMarket/quoteOffer match direct (baseId, quoteId) pairs only. Planned: treat markets as directed edges over canonical asset ids and route through intermediates (BTC → USDT → USDC) via findRoutes / planRoute / quoteRoute, ranking routes by the compounded net multiplier ∏(1 − (fee_bps + safety_bps)/10000) and checking size limits per hop at plan time. Note the protocol caveat: each hop executes as a separate Arkade offer, so a chained route is not atomic — plans are indicative and routing will be opt-in, never a silent fallback inside quoteOffer(). Full spec and API design: #1.

Develop

pnpm install
pnpm test        # node --test, runs the .ts directly
pnpm typecheck
pnpm build       # emits dist/*.js + *.d.ts

Release

From the repo root, publish a patch, minor, or major release:

npm run release:client -- patch

The last argument must be patch, minor, or major. The script must be run from master with a clean git tree, runs the package tests/typecheck/build, bumps packages/discovery-client/package.json with npm version <level> --no-git-tag-version, publishes to npm, then creates a release commit and a solver-discovery-vX.Y.Z tag, and pushes both master and the tag to origin.

You can also run the same workflow from this package directory:

npm run release -- minor

Before running it, make sure you are logged in to npm with publish rights for the @arkade-os scope.