@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/curvesand@noble/hashes, for card signatures;fetchis 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
BigIntrationals and amounts areBigInt, so no float error ever touches an amount.
Install
npm install @arkade-os/solver-discoveryQuick 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
quoteOfferthrows on them (price_feedwas assumed present; its type is nowstring | 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, andquote_corridorare gone from solver cards, andSelectOptions.baseCorridor/quoteCorridorare removed along with them. Pass the fully-qualified id (e.g."bolt11:bitcoin/slip44:0", not"btc"+quoteCorridor: "lightning") tobaseId/quoteIdinstead.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
idis down-projected to the v0 grammar ("btc"or a 68-hex AssetId) and the CAIP-19 identity travels incaip19_idbeside it — read it viaassetIdOf, and pass CAIP-19 tobaseId/quoteIdas 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 allowPricing 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.tsRelease
From the repo root, publish a patch, minor, or major release:
npm run release:client -- patchThe 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 -- minorBefore running it, make sure you are logged in to npm with publish rights for
the @arkade-os scope.
