@dcasegr/amm-client 0.39.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -15,22 +15,23 @@
15
15
  * (`hostedTokens`): each token's price, liquidity (TVL) and pool count; no host-wide stream.
16
16
  * - **History** (0.39.0, D1–D6): `trades`, `trade`, `candles`, `stats`, `statsAll`, `holders`,
17
17
  * `positionsAll` — plain GETs of the history routes (H1–H6, src/lib/overlay.ts).
18
- * - **Swap** (D11–D16): the token's pools route `GET /@<token>/amm/pools` held as an event stream
19
- * (`watchTokenPools`; 0.39.0, D7: the whole answer once, then one `change` per pool change,
20
- * applied to the held answer): the pools whose validator is beating and whose commission meets the
21
- * market's minimum (the host's filter), the market's commission key hash; a quote is the
22
- * matching engine's plan over that list (no request); a swap reads ONE `GET
23
- * /@<token>/amm/pools/beef?lp=` for its legs (freshness, the pools' BEEF), then each leg's relay
24
- * request (`amm.swap.submit`, held by the host until the relay rests; 15 s expiry); a lost answer
25
- * is "no answer yet" with "Check again" (`GET /@<token>/amm/swaps?id=`, once).
26
- * - **Liquidity** (D9, D23): the wallet's pool rows and deploy history, then ONE `GET
27
- * /@<token>/amm/positions?lp=` per token; a deploy the history has but the basket lacks is
28
- * filed then; Close and Rebalance read their pool's BEEF by LP key (`/pools/beef?lp=`); the
29
- * deploy's terms `GET /@<token>/amm/terms`.
18
+ * - **Swap** (D11–D16; 0.40.0; 0.41.0): the token's pools route `GET /@<token>/amm/pools`, a plain
19
+ * read (`tokenPools`): the pools whose validator is beating and whose commission meets the
20
+ * market's minimum (the host's filter), each validator's minimum leg, the market's commission key
21
+ * hash; a quote is the matching engine's plan over that list (no request); a swap (`executeSwap`)
22
+ * stages what the swap basket lacks by ONE wallet action, then per leg the pools route read fresh,
23
+ * ONE `GET /@<token>/amm/pools/beef?pool=` and the leg's relay request (`amm.swap.submit`, held by
24
+ * the host until the relay rests; 15 s expiry); no answer → the host's `GET /amm/spends`, once
25
+ * more, a race. The pool events (`watchPoolEvents`, 0.41.0: the host's feeds) are signals for
26
+ * display: a page reads the route again on one.
27
+ * - **Liquidity** (D9, D23; 0.40.0: by pool id): the wallet's pool rows and deploy history, then
28
+ * ONE `GET /@<token>/amm/positions?pool=` per token; a deploy the history has but the basket
29
+ * lacks is filed then; Close and Rebalance read their pool's BEEF by its id (`/pools/beef?pool=`);
30
+ * the deploy's terms `GET /@<token>/amm/terms`.
30
31
  * - **Your tokens** (D17, D18, D19, D24): the wallet's module and the swap basket; the token list
31
- * once and ONE discovery `GET /@discovery/1color/tokens?ids=` for held tokens the list lacks; a
32
- * silent pass over the swap basket's staged legs (src/wallet/swapPayouts.ts); Move back submits
33
- * at the host root.
32
+ * once and ONE discovery `GET /@discovery/1color/tokens?ids=` for held tokens the list lacks; the
33
+ * pass over the swap basket (0.40.0: ONE `GET /amm/spends` over every row,
34
+ * src/wallet/swapPayouts.ts); Move back submits at the host root.
34
35
  * - **Settings** (D20, D21): the claim card (`claimStatus`: swap's identity, the recipient of
35
36
  * root's messages), the token list once, discovery's list and ONE `?ids=` read; Enable's deploy
36
37
  * BEEF from the wallet's basket else `GET /@discovery/1color/<token>/deploy`.
@@ -42,18 +43,17 @@
42
43
  */
43
44
  import { Beef, Transaction } from "@bsv/sdk";
44
45
  import { send1Color } from "@1sat/actions";
45
- import { computeSwap } from "@dcasegr/amm-engine";
46
+ import { nextLeg, priceOf } from "@dcasegr/amm-engine";
46
47
  import { outpointText, parseOutpoint, parseTokenId, sdkTokenId, tokenIdText } from "./lib/tokenId.js";
47
48
  import { DEFAULT_FEE_RATE_SATS_PER_KB, appName, exchangeBase, tokenBase } from "./lib/config.js";
48
- import { ammLookupService, applyPoolChange, liveOfPools, parsePools, parseTokenTopic, poolOutputOf, poolsUrl, readDiscoveryDeploy, readDiscoveryIds, readDiscoveryList, readAllPositions, readAllStats, readCandles, readHolders, readPoolsBeef, readPools, readPositions, readStats, readTerms, readTrade, readTrades, readTx, watchPools, } from "./lib/overlay.js";
49
+ import { ammLookupService, liveOfPools, parseTokenTopic, poolOutputOf, readDiscoveryDeploy, readDiscoveryIds, readDiscoveryList, readAllPositions, readAllStats, readCandles, readHolders, readPoolHistory, readPoolsAt, readPoolsBeef, readPools, readPositions, readSpendsAll, readStats, readTerms, readTrade, readTrades, readTx, eventsUrl, watchPoolEvents as watchFeed, } from "./lib/overlay.js";
49
50
  import { authFetchPerInstance, instanceBase, readDefaults, readDiscovery, readTokens, sendMessage } from "./lib/skein.js";
50
51
  import { depthOf } from "./market/prices.js";
51
- import { buildPlanRequest, goneFromLookup, quote as planQuote } from "./market/plan.js";
52
- import { validatorStatus } from "./market/view.js";
53
- import { planSwapLeg, prepareSwap, selectExactSatsInput, selectExactTokenInputs } from "./market/swapAction.js";
54
- import { moveBack, sendSatsToSwapBasket, sendToSwapBasket, swapHoldingBeef, swapHoldings, swapSatsHoldings, swapTotals } from "./market/activeSwap.js";
55
- import { checkAgain, relaySwap, swapIdOf } from "./market/swapFlow.js";
56
- import { settleStagedSwaps } from "./wallet/swapPayouts.js";
52
+ import { buildPlanRequest, legMinerFeeSats, quote as planQuote } from "./market/plan.js";
53
+ import { prepareSwap, selectLegInputs } from "./market/swapAction.js";
54
+ import { fileStagedToken, moveBack, moveBackSats, stage, swapHoldingBeef, swapHoldings, swapSatsHoldings, swapSatsTotal, swapTotals, unfiledStagedTokens } from "./market/activeSwap.js";
55
+ import { relaySwap, swapIdOf } from "./market/swapFlow.js";
56
+ import { resolveFromOverlay } from "./wallet/swapPayouts.js";
57
57
  import { formatAmount } from "./lp/amounts.js";
58
58
  import { basketRows } from "./lp/wallet.js";
59
59
  import { buildInventory, decodeTokenRow, deployIcon } from "./lp/inventory.js";
@@ -63,7 +63,7 @@ import { rebalancePool } from "./lp/rebalance.js";
63
63
  import { historyPoolRows, mergePoolRows, positionRowsOf } from "./lp/positions.js";
64
64
  import { POOLS_BASKET, assetBasket, heldOutputs } from "./wallet/color.js";
65
65
  import { prepareClose, submitToOverlay } from "./lp/close.js";
66
- import { ClientError, errorKind, kindOfOutcome, toClientError } from "./errors.js";
66
+ import { ClientError, toClientError } from "./errors.js";
67
67
  /**
68
68
  * `amm.pool.terms`'s refusal when no terms are set (0.15.0, David Case
69
69
  * 2026-10-23: terms are a runtime record root sets; unset, a market only).
@@ -75,7 +75,7 @@ export const NO_TERMS_TEXT = "This exchange validates nothing: no terms are set.
75
75
  export function hasTerms(t) {
76
76
  return !!t && !("none" in t);
77
77
  }
78
- export { applyPoolChange, parseChange } from "./lib/overlay.js";
78
+ export { parsePoolEventMessage } from "./lib/overlay.js";
79
79
  /** The metadata of the deploy output `vout` of an Atomic BEEF's subject; null when that output is no 1Color deploy. */
80
80
  function deployMetaOf(beef, vout) {
81
81
  const tx = Transaction.fromAtomicBEEF(beef);
@@ -123,6 +123,7 @@ export function hostedFrom(list, stats) {
123
123
  export function poolViews(answer, tokenId) {
124
124
  return (answer?.pools ?? []).map((p) => ({
125
125
  outpoint: outpointText(p.outpoint),
126
+ poolId: p.poolId,
126
127
  tokenId,
127
128
  sats: p.bsvReserve,
128
129
  tokens: p.tokenReserve,
@@ -133,13 +134,29 @@ export function poolViews(answer, tokenId) {
133
134
  export function poolFees(p) {
134
135
  return { lpBps: Number(p.liquidityFeeBps), validatorBps: Number(p.validationFeeBps), commissionBps: Number(p.commissionBps ?? 0n) };
135
136
  }
136
- function swapResultOf(legs) {
137
- const filled = legs.filter((l) => l.status === "filled").length;
138
- return { status: filled === legs.length && legs.length > 0 ? "filled" : filled === 0 ? "failed" : "partial", legs };
137
+ /**
138
+ * A pools answer's rows as the planner takes them (0.40.0): each pool with its validator's
139
+ * minimum leg (`validators[].minLegSats`, the validator's announced terms).
140
+ */
141
+ export function poolsWithMinimums(answer) {
142
+ const min = new Map(answer.validators.map((v) => [v.identity, v.minLegSats]));
143
+ return answer.pools.map((p) => {
144
+ const m = min.get(p.validatorIdentityKey.toLowerCase());
145
+ return m ? { ...p, minLegSats: m } : p;
146
+ });
147
+ }
148
+ /** Sats per whole token of an exact pair (`sats` per `tokens` base units), 0 when `tokens` is 0. */
149
+ export function pairPrice(p, dec) {
150
+ return p.tokens > 0n ? Number(p.sats) / (Number(p.tokens) / 10 ** dec) : 0;
139
151
  }
140
- /** A swap's result with one leg replaced (after "Check again"). */
141
- export function withLeg(r, leg) {
142
- return swapResultOf(r.legs.map((l) => (l.poolOutpoint === leg.poolOutpoint ? leg : l)));
152
+ /**
153
+ * A leg's sats beyond its amount in (0.40.0, the stage's fee sats): the miner fee estimate plus its
154
+ * 1-sat carriers — on a buy the token payout; on a sell the token fee and commission outputs that
155
+ * are not zero, and the token change.
156
+ */
157
+ export function legCarrySats(leg, buy, minerFeeSats) {
158
+ const carriers = buy ? 1n : BigInt([leg.liquidityFee, leg.validationFee, leg.commission].filter((x) => x > 0n).length + 1);
159
+ return minerFeeSats + carriers;
143
160
  }
144
161
  /**
145
162
  * wallet-toolbox's `specOpWalletBalance` (`sdk.specOpWalletBalance`, @bsv/wallet-toolbox-client): the listOutputs "basket" of its
@@ -224,7 +241,17 @@ export function enableCandidates(listed, walletIds, onList) {
224
241
  }
225
242
  return out;
226
243
  }
227
- const newShared = () => ({ listRead: null, claimRead: null, planned: new WeakMap(), checks: new Map(), loaded: new Map(), listeners: new Set() });
244
+ /** The terms as root's messages carry them (0.40.0: `minLegSats` only when above 0). Refused unless each fee is an integer 0 to 10 000 and the minimum a whole number of sats. */
245
+ function termsArgs(t) {
246
+ for (const [k, v] of Object.entries({ lpBps: t.lpBps, validatorBps: t.validatorBps, commissionBps: t.commissionBps }))
247
+ if (!Number.isInteger(v) || v < 0 || v > 10_000)
248
+ throw new Error(`${k}: an integer, 0 to 10000 basis points`);
249
+ const min = t.minLegSats ?? 0;
250
+ if (!Number.isSafeInteger(min) || min < 0)
251
+ throw new Error("minLegSats: a whole number of sats, 0 for none");
252
+ return { lpFeeBps: t.lpBps, validatorFeeBps: t.validatorBps, commissionBps: t.commissionBps, ...(min > 0 ? { minLegSats: min } : {}) };
253
+ }
254
+ const newShared = () => ({ listRead: null, claimRead: null, planned: new WeakMap(), swapping: false, loaded: new Map(), listeners: new Set() });
228
255
  /**
229
256
  * The client of one exchange (D9): its reads over `origin` (src/lib/overlay.ts, src/lib/skein.ts),
230
257
  * the swap flow, the pool flows (deploy, close, rebalance, move back, positions) and the wallet
@@ -244,7 +271,6 @@ function makeClient(opts, s) {
244
271
  const boundWallet = opts.wallet ?? null;
245
272
  const signedFor = boundWallet ? (opts.signedFor ?? authFetchPerInstance(boundWallet, f)) : null;
246
273
  const planned = s.planned;
247
- const checks = s.checks;
248
274
  const loaded = s.loaded;
249
275
  function needWallet() {
250
276
  if (!boundWallet || !signedFor)
@@ -470,16 +496,20 @@ function makeClient(opts, s) {
470
496
  const [list, stats] = await Promise.all([enabledList(), statsAll().catch(() => null)]);
471
497
  return hostedFrom(list, stats);
472
498
  }
499
+ /** P2 (0.41.0): a token's pools as the host lists them now, read past the browser's cache (`freshFetch`). */
500
+ async function tokenPools(tokenId) {
501
+ return readPools(tokenBase(tokenId, swapBase), freshFetch);
502
+ }
473
503
  /**
474
- * A token's pools route held as an event stream (D16; 0.39.0, D7): the whole answer (`pools`)
475
- * held, each `change` applied to it (`applyPoolChange`), one it cannot apply → the route read once
476
- * again; each new answer to `onAnswer`. → the unsubscribe.
504
+ * The host's pool event feed (E1, 0.41.0): one token's (`GET /@<token>/amm/events`) or, with
505
+ * `"all"`, every enabled token's (`GET <origin>/amm/events`); each message `{tokenId, …row}` to
506
+ * `onEvent`. Signals for display: the pools route is the truth. → the unsubscribe.
477
507
  */
478
- function watchTokenPools(tokenId, onAnswer, onError) {
479
- return watchPools(poolsUrl(tokenBase(tokenId, swapBase)), parsePools, onAnswer, { ...(onError ? { onError } : {}), fetch: f, change: applyPoolChange });
508
+ function watchPoolEvents(tokenId, onEvent, onError) {
509
+ return watchFeed(eventsUrl(tokenId === "all" ? swapBase : tokenBase(tokenId, swapBase)), onEvent, onError ? { onError } : {});
480
510
  }
481
511
  // ---------------------------------------------------------------- history (0.39.0, H1–H6)
482
- /** H1: a token's trades, newest first (`from ≤ at < to` ms, `limit` 1–500, `before` the cursor `next`, `lp` one LP key's). */
512
+ /** H1: a token's trades, newest first (`from ≤ at < to` ms, `limit` 1–500, `before` the cursor `next`, 0.40.0 `pool` one pool's by id). */
483
513
  async function trades(tokenId, o = {}) {
484
514
  return readTrades(tokenBase(tokenId, swapBase), o, ff);
485
515
  }
@@ -487,107 +517,128 @@ function makeClient(opts, s) {
487
517
  async function trade(tokenId, txid) {
488
518
  return readTrade(tokenBase(tokenId, swapBase), txid, ff);
489
519
  }
490
- /** H2: a token's candles, one per non-empty bucket of `tf` over `from ≤ at < to` (ms). */
520
+ /** H2: a token's candles, one per non-empty bucket of `tf` over `from ≤ at < to` (ms); 0.40.0 `series`: `fill` (the trades', default) or `market` (the price after each pool event, the host's). */
491
521
  async function candles(tokenId, o) {
492
522
  return readCandles(tokenBase(tokenId, swapBase), o, ff);
493
523
  }
524
+ /** H7 (0.40.0): every pool of the token listed at `t` (ms). */
525
+ async function poolsAt(tokenId, t) {
526
+ return readPoolsAt(tokenBase(tokenId, swapBase), t, ff);
527
+ }
528
+ /** H8 (0.40.0): the token's pool events in `from ≤ at < to`, oldest first (`pool` one pool's; forward paging: the page's `next` as `after`). */
529
+ async function poolHistory(tokenId, o = {}) {
530
+ return readPoolHistory(tokenBase(tokenId, swapBase), o, ff);
531
+ }
532
+ /** H9 (0.40.0): each outpoint's spender as the host's `/amm/spends` answers it (every enabled token's `/spends`). */
533
+ async function spendsAll(outpoints) {
534
+ return readSpendsAll(swapBase, outpoints, ff);
535
+ }
494
536
  /** H3: a token's stats. */
495
537
  async function stats(tokenId) {
496
538
  return readStats(tokenBase(tokenId, swapBase), ff);
497
539
  }
498
540
  /** H4: every enabled token's stats (`ids`: those only, in that order). */
499
541
  async function statsAll(o = {}) {
500
- return readAllStats(swapBase, o.ids, ff);
542
+ return readAllStats(swapBase, o.ids, o.fresh ? freshFetch : ff);
501
543
  }
502
544
  /** H6: a token's holders, largest first (`limit` 1–100, `skip`). */
503
545
  async function holders(tokenId, o = {}) {
504
546
  return readHolders(tokenBase(tokenId, swapBase), tokenId, o, ff);
505
547
  }
506
- /** H5: the LP keys' positions on every enabled token. */
507
- async function positionsAll(lpKeys) {
508
- return readAllPositions(swapBase, lpKeys, ff);
548
+ /** H5: the pools' positions on every enabled token (0.40.0: by pool id). */
549
+ async function positionsAll(poolIds) {
550
+ return readAllPositions(swapBase, poolIds, ff);
509
551
  }
510
552
  /**
511
553
  * A quote for one token (D12): the matching engine's plan over the pools the Swap page holds
512
554
  * (`held`: the token's pools route's answer — the host listed only pools whose validator is beating
513
- * and whose commission meets the market's minimum); no request. `meta`: the token's symbol and
514
- * decimals.
555
+ * and whose commission meets the market's minimum), each pool with its validator's minimum leg
556
+ * (0.40.0); no request. `meta`: the token's symbol and decimals. 0.40.0: the price and impact from
557
+ * the engine's `priceOf` over the same pools (the buy side for a buy, the sell side for a sell);
558
+ * each leg's own fees; what Swap will stage (the swap amount exact, 0.40.2: the fee sats of
559
+ * `maxSources` legs) and the per-leg miner fee estimate.
515
560
  */
516
561
  function quoteSwap(req, held, meta) {
517
562
  if (req.amountIn <= 0n)
518
563
  throw new Error("Enter an amount");
519
- const pools = held?.pools ?? [];
520
- if (pools.length === 0)
564
+ if (!held || held.pools.length === 0)
521
565
  throw new Error("No pool to swap against");
566
+ const pools = poolsWithMinimums(held);
522
567
  const live = liveOfPools(pools);
523
568
  const dec = meta.dec;
524
569
  const buy = req.side === "buy";
525
- const r = buildPlanRequest(req.tokenId, { direction: buy ? "bsvToToken" : "tokenToBsv", amount: formatAmount(req.amountIn, buy ? 0 : dec), slippageBps: String(req.maxSlippageBps), allowPartial: req.allowPartial }, pools, live, dec, feeRate);
570
+ const r = buildPlanRequest(req.tokenId, { direction: buy ? "bsvToToken" : "tokenToBsv", amount: formatAmount(req.amountIn, buy ? 0 : dec), slippageBps: String(req.maxSlippageBps), maxSources: req.maxSources }, pools, live, dec, feeRate);
526
571
  if (!r.ok)
527
572
  throw new Error(r.error);
528
573
  const { plan, view } = planQuote(r.request, live, dec);
574
+ if (plan.unfillable === "min")
575
+ throw new ClientError("below_minimum", `No pool here takes a swap this small: at least ${formatAmount(plan.minAmountIn ?? 0n, buy ? 0 : dec)} ${buy ? "sats" : meta.sym}.`);
529
576
  const used = view.legs.filter((l) => l.amountIn > 0n);
530
577
  if (used.length === 0 || view.totalOut === 0n)
531
578
  throw new Error("The amount is too small to receive anything");
532
- const sumSats = pools.reduce((a, p) => a + p.bsvReserve, 0n);
533
- const sumTokens = pools.reduce((a, p) => a + p.tokenReserve, 0n);
534
- const marginal = sumTokens > 0n ? Number(sumSats) / (Number(sumTokens) / 10 ** dec) : 0;
579
+ const price = priceOf(pools);
580
+ const marginal = price ? pairPrice(buy ? price.buy : price.sell, dec) : 0;
535
581
  const totalIn = used.reduce((a, l) => a + l.amountIn, 0n);
536
582
  const sats = buy ? totalIn : view.totalOut;
537
583
  const tokens = buy ? view.totalOut : totalIn;
538
584
  const effective = Number(sats) / (Number(tokens) / 10 ** dec);
539
- const first = used[0].pool;
585
+ const minerFee = legMinerFeeSats(feeRate);
586
+ const carry = plan.legs.map((l) => legCarrySats(l, buy, minerFee));
587
+ // 0.40.2: the fee sats for EXACTLY maxSources legs — the planned legs' own, each further leg at the largest of theirs.
588
+ const more = BigInt(Math.max(0, req.maxSources - carry.length));
589
+ const feeSats = carry.reduce((a, x) => a + x, 0n) + more * carry.reduce((a, x) => (x > a ? x : a), 0n);
540
590
  const q = {
541
591
  request: req,
542
592
  legs: used.map((l) => ({
543
593
  poolOutpoint: outpointText(l.outpoint),
594
+ poolId: l.pool.poolId,
544
595
  amountIn: l.amountIn,
545
596
  amountOut: l.amountOut,
546
597
  shareBps: Number((l.amountIn * 10000n) / req.amountIn),
547
598
  reserves: { sats: l.pool.bsvReserve, tokens: l.pool.tokenReserve },
548
599
  validator: { identityKey: l.pool.validatorIdentityKey, live: l.validator.live },
600
+ fees: poolFees(l.pool),
549
601
  })),
550
602
  amountOut: view.totalOut,
551
603
  minAmountOut: view.minAmountOut,
552
604
  effectivePrice: effective,
553
605
  marginalPrice: marginal,
554
606
  impactBps: marginal ? Math.round((Math.abs(effective - marginal) / marginal) * 10_000) : 0,
555
- fees: { lpBps: Number(first.liquidityFeeBps), validatorBps: Number(first.validationFeeBps), commissionBps: Number(first.commissionBps ?? 0n) },
607
+ stage: { amount: req.amountIn, feeSats },
608
+ legMinerFeeSats: minerFee,
556
609
  };
557
- planned.set(q, { plan, pools, live, market: held.market, dec, sym: meta.sym });
610
+ planned.set(q, { plan, answer: held, dec, sym: meta.sym });
558
611
  return q;
559
612
  }
560
- /** A relay outcome on a leg's result (0.31.1's mapping; 0.34.0: no answer carries its id for "Check again"). */
561
- function onLeg(result, o, prepared, base, af) {
562
- delete result.checkId;
563
- if (o.status === "accepted") {
564
- result.status = "filled";
565
- result.txid = o.txid;
566
- delete result.error;
567
- delete result.kind;
568
- checks.delete(o.id);
569
- return;
570
- }
571
- result.status = "failed";
572
- const kind = kindOfOutcome(o);
573
- if (kind)
574
- result.kind = kind;
575
- const rest = o.status === "unknown" ? o.reason.replace(/^no answer yet:? ?/, "") : "";
576
- result.error = o.status === "refused" ? `refused: ${o.reason}` : o.status === "unknown" ? `no answer yet${rest ? `: ${rest}` : ""}` : o.status;
577
- if (o.status === "unknown") {
578
- result.checkId = o.id;
579
- checks.set(o.id, { prepared, base, af });
580
- }
581
- else if ("id" in o)
582
- checks.delete(o.id);
613
+ /** The overlay pass over the swap basket (item 5, src/wallet/swapPayouts.ts): ONE `/amm/spends`, each spender's `/tx`, this wallet's legs taken in. */
614
+ function resolve(wallet) {
615
+ return resolveFromOverlay(wallet, { spendsAll: (ops) => readSpendsAll(swapBase, ops, freshFetch), tx: (tokenId, txid) => readTx(tokenBase(tokenId, swapBase), txid, freshFetch) });
583
616
  }
584
617
  /**
585
- * Runs a quoted swap with the connected wallet (D14, D15): ONE `GET /@<token>/amm/pools/beef?lp=`
586
- * for the legs' LP keys — a leg whose key is missing or whose outpoint moved is gone since the
587
- * quote (as before 0.34.0) — the commission key hash and each validator's peer ID from the pools
588
- * the page holds; then one transaction per leg, relayed to the leg's validator (`amm.swap.submit`,
589
- * its answer the settled record), the payout taken in on acceptance. A planned pool gone stops the
590
- * swap unless partial fills are allowed.
618
+ * Runs a quoted swap with the connected wallet (0.40.0, agent-env plans/swap-balance-build-2026-11-05.md
619
+ * §0 7, Builder 2 item 4): ONE swap at a time, legs ONE AT A TIME.
620
+ *
621
+ * 1. The overlay pass (`resolve`); then what `amm-swap` lacks of what the plan needs — the swap
622
+ * amount exact, the sats for the carriers and miner fees of `maxSources` legs —
623
+ * staged by ONE `stage` (`staging`).
624
+ * 2. The loop: cancelled → `cancelled`; the SERVER's pool list read fresh (`GET
625
+ * /@<token>/amm/pools`, no cache; 0.41.0: before EACH leg, no list of its own kept);
626
+ * `nextLeg` over it minus the excluded outpoints, `minOut` the quote's floor less what has
627
+ * filled, `maxSources` (0.40.2) the request's less the legs filled → its stop ends the swap;
628
+ * the pool's current output read by its id (`GET /pools/beef?pool=`; moved → the list read
629
+ * again); the staged rows that cover the leg, else `fees`; built, signed, relayed
630
+ * (`relaying`):
631
+ * - accepted → taken in (ONE internalizeAction) before the next leg;
632
+ * - refused `pool_spent` (`repricing`) → the list read again: the relaying host took the
633
+ * validator's state in before it answered (0.41.0);
634
+ * - refused otherwise (`below_minimum` included) → that outpoint excluded;
635
+ * - no answer → (a) the overlay: a staged input spent → the leg landed (taken in by the
636
+ * pass), the pool spent by another's swap → continue with the same rows; (b) else the
637
+ * same leg sent ONCE more to the same validator; (c) still none → that outpoint excluded
638
+ * (the next leg races it with the same rows);
639
+ * - after any refusal: the overlay pass.
640
+ * 3. Filled completely: the last leg's change went to the wallet, nothing left. Stopped early with
641
+ * rows left: `moveBack` (one wallet action, `returning`); declined or failed → the rows stay.
591
642
  */
592
643
  async function executeSwap(quote, o = {}) {
593
644
  const progress = (x) => {
@@ -598,26 +649,26 @@ function makeClient(opts, s) {
598
649
  /* the caller's callback: not the swap's failure */
599
650
  }
600
651
  };
652
+ if (s.swapping)
653
+ throw new Error("A swap is already running: one at a time");
654
+ s.swapping = true;
601
655
  let result;
602
656
  try {
603
- result = await runSwap(quote, progress);
657
+ result = await runSwap(quote, progress, o.signal);
604
658
  }
605
659
  catch (e) {
606
660
  const err = toClientError(e);
607
661
  progress({ phase: "failed", error: err instanceof Error ? err : new Error(String(err)) });
608
662
  throw err;
609
663
  }
664
+ finally {
665
+ s.swapping = false;
666
+ }
667
+ emit();
610
668
  progress(result.status === "failed" ? { phase: "failed", result } : { phase: "done", result });
611
669
  return result;
612
670
  }
613
- /** A leg failed with `e`: its text, and its kind when it has one (D13). */
614
- function failLeg(result, e) {
615
- result.error = errText(e);
616
- const kind = errorKind(e);
617
- if (kind)
618
- result.kind = kind;
619
- }
620
- async function runSwap(quote, progress) {
671
+ async function runSwap(quote, progress, signal) {
621
672
  const { wallet, signedFor } = needWallet();
622
673
  const p = planned.get(quote);
623
674
  if (!p)
@@ -625,212 +676,153 @@ function makeClient(opts, s) {
625
676
  const tokenId = quote.request.tokenId;
626
677
  const base = tokenBase(tokenId, swapBase);
627
678
  const af = signedFor(base);
628
- const lpOf = (outpoint) => p.pools.find((x) => x.outpoint === outpoint)?.lpPubKey ?? "";
629
- const keys = [...new Set(p.plan.legs.map((l) => lpOf(l.outpoint)).filter((k) => k !== ""))];
630
- const fresh = await readPoolsBeef(base, keys, freshFetch);
631
- const gone = goneFromLookup(p.plan, fresh.pools);
632
- if (gone.length > 0 && !quote.request.allowPartial)
633
- throw new ClientError("pool_spent", `${gone.map(outpointText).join(", ")} ${gone.length === 1 ? "was" : "were"} spent since the quote: quote again`);
634
- const commissionPkh = p.market.commissionPkh;
635
- const direction = quote.request.side === "buy" ? "bsvToToken" : "tokenToBsv";
679
+ const buy = quote.request.side === "buy";
680
+ const direction = buy ? "bsvToToken" : "tokenToBsv";
681
+ const minerFee = legMinerFeeSats(feeRate);
682
+ const meta = { sym: p.sym, dec: p.dec };
683
+ // 0.41.0: the server's list, read fresh before each leg; the outpoints excluded (a pool that refused other than pool_spent, or did not answer).
684
+ const excluded = new Set();
685
+ /** 0.40.2: the legs the order may still fill (the request's maxSources less the legs filled). */
686
+ let budget = quote.request.maxSources;
636
687
  const legs = [];
637
- const running = [];
638
- // The legs that can run: not spent since the quote, their validator live.
639
- const runnable = [];
640
- for (const leg of p.plan.legs) {
641
- const poolOutpoint = outpointText(leg.outpoint);
642
- if (gone.includes(leg.outpoint)) {
643
- legs.push({ poolOutpoint, status: "failed", error: "spent since the quote", kind: "pool_spent" });
644
- continue;
645
- }
646
- const pool = p.pools.find((x) => x.outpoint === leg.outpoint);
647
- const validator = validatorStatus(pool.validatorIdentityKey, p.live);
648
- if (!validator.live || !validator.peerId) {
649
- legs.push({ poolOutpoint, status: "failed", error: "the pool's validator is no longer live" });
650
- continue;
688
+ let filledIn = 0n;
689
+ let filledOut = 0n;
690
+ let order;
691
+ /** Legs sent with no answer yet: what they spend, to account a later take-in by the overlay pass. */
692
+ const pending = [];
693
+ const accountResolved = (taken) => {
694
+ for (const t of taken) {
695
+ const i = pending.findIndex((x) => x.prepared.satsInputs.some((r) => t.inputs.includes(r.outpoint)));
696
+ if (i < 0)
697
+ continue;
698
+ const [x] = pending.splice(i, 1);
699
+ order ??= x.prepared.order;
700
+ budget -= 1;
701
+ filledIn += x.leg.amountIn;
702
+ filledOut += x.leg.amountOut;
703
+ legs.push({ poolOutpoint: outpointText(x.leg.outpoint), poolId: x.poolId, amountIn: x.leg.amountIn, amountOut: x.leg.amountOut, txid: t.txid });
651
704
  }
652
- const result = { poolOutpoint, status: "failed" };
653
- legs.push(result);
654
- runnable.push({ leg, result, pool, peerId: validator.peerId });
705
+ };
706
+ accountResolved(await resolve(wallet));
707
+ // 1. Stage what the basket lacks.
708
+ const needTokens = buy ? 0n : quote.stage.amount;
709
+ const needSats = (buy ? quote.stage.amount : 0n) + quote.stage.feeSats;
710
+ const haveSats = BigInt(await swapSatsTotal(wallet));
711
+ const haveTokens = buy ? 0n : (await swapHoldings(wallet, tokenId)).reduce((a, t) => a + t.amount, 0n);
712
+ const lackSats = needSats > haveSats ? needSats - haveSats : 0n;
713
+ const lackTokens = needTokens > haveTokens ? needTokens - haveTokens : 0n;
714
+ if (lackSats > 0n || lackTokens > 0n) {
715
+ progress({ phase: "staging", sats: lackSats, tokens: lackTokens });
716
+ await stage(wallet, { sats: Number(lackSats), ...(lackTokens > 0n ? { tokens: { tokenId, amount: lackTokens } } : {}) }, { sym: p.sym, submit: submitAt(base) });
655
717
  }
656
- // 0.29.0: every leg spends only `amm-swap` outputs — tokens in, outputs carrying exactly its
657
- // amount in (0.12.0); and one sats output of exactly the leg's sats. What the basket does not
658
- // cover is staged by ONE send (tokens missing: the module send, the sats with it; else a plain
659
- // sats send), then each leg is built from the basket.
660
- const tokensIn = direction === "tokenToBsv";
661
- if (runnable.length > 0)
662
- progress({ phase: "staging" });
663
- let tokenCandidates = tokensIn && runnable.length > 0 ? await swapHoldings(wallet, tokenId) : [];
664
- let satsCandidates = runnable.length > 0 ? await swapSatsHoldings(wallet) : [];
665
- const staged = [];
666
- for (const r of runnable) {
667
- try {
668
- const poolOutput = poolOutputOf(fresh, r.leg.outpoint);
669
- if (!poolOutput)
670
- throw new Error("the pools read has no transaction for this pool");
671
- let tokens = null;
672
- if (tokensIn) {
673
- tokens = selectExactTokenInputs(tokenCandidates, r.leg.amountIn);
674
- if (tokens)
675
- tokenCandidates = tokenCandidates.filter((c) => !tokens.includes(c));
676
- }
677
- // A leg whose tokens are still to be staged gets one output of 1 sat (sendToSwapBasket).
678
- const planned = await planSwapLeg({ wallet, tokenId, meta: { sym: p.sym, dec: p.dec }, direction, leg: r.leg, pool: r.pool, poolOutput, commissionPkh, ...(tokensIn ? { tokenInputs: tokens ?? [{ satoshis: 1, amount: r.leg.amountIn }] } : {}), satsPerKb: feeRate });
679
- const sats = selectExactSatsInput(satsCandidates, planned.funding.satoshis);
680
- if (sats)
681
- satsCandidates = satsCandidates.filter((c) => c !== sats);
682
- staged.push({ r, poolOutput, planned, tokens, sats });
718
+ // 2. The loop.
719
+ let why = "filled";
720
+ for (;;) {
721
+ if (signal?.aborted) {
722
+ why = "cancelled";
723
+ break;
683
724
  }
684
- catch (e) {
685
- failLeg(r.result, e);
725
+ const remaining = quote.request.amountIn - filledIn;
726
+ const answer = await tokenPools(tokenId);
727
+ const pools = poolsWithMinimums(answer).filter((r) => !excluded.has(r.outpoint));
728
+ const minOut = quote.minAmountOut > filledOut ? quote.minAmountOut - filledOut : 0n;
729
+ const next = nextLeg({ pools, direction, remaining, fixedCost: { minerFeeSats: minerFee }, minOut, maxSources: budget });
730
+ if ("stop" in next) {
731
+ why = next.stop;
732
+ break;
686
733
  }
687
- }
688
- const shortTokens = staged.filter((x) => tokensIn && !x.tokens);
689
- const shortSats = staged.filter((x) => !x.sats);
690
- let sent;
691
- if (shortTokens.length > 0) {
692
- const s = await sendToSwapBasket(wallet, base, tokenId, { tokens: shortTokens.map((x) => ({ amount: x.r.leg.amountIn, pool: x.r.leg.outpoint })), sats: shortSats.map((x) => ({ satoshis: x.planned.funding.satoshis, pool: x.r.leg.outpoint })) }, { sym: p.sym }, submitAt(base));
693
- sent = s.tx;
694
- const why = s.errors.length ? ` (${s.errors.join("; ")})` : "";
695
- shortTokens.forEach((x, n) => {
696
- const h = s.holdings[n];
697
- if (h)
698
- x.tokens = [h];
699
- else
700
- x.r.result.error = `the swap basket send has no output for this leg${why}`;
701
- });
702
- shortSats.forEach((x, n) => {
703
- const h = s.sats[n];
704
- if (h)
705
- x.sats = h;
706
- else
707
- x.r.result.error ??= `the swap basket send has no sats output for this leg${why}`;
708
- });
709
- }
710
- else if (shortSats.length > 0) {
711
- const s = await sendSatsToSwapBasket(wallet, tokenId, shortSats.map((x) => ({ satoshis: x.planned.funding.satoshis, pool: x.r.leg.outpoint })), { sym: p.sym });
712
- sent = s.tx;
713
- const why = s.errors.length ? ` (${s.errors.join("; ")})` : "";
714
- shortSats.forEach((x, n) => {
715
- const h = s.sats[n];
716
- if (h)
717
- x.sats = h;
718
- else
719
- x.r.result.error = `the swap basket send has no sats output for this leg${why}`;
720
- });
721
- }
722
- const holdingBeef = staged.length > 0 ? await swapHoldingBeef(wallet, sent) : undefined;
723
- // The legs sent to the relay: their expected amountOut, and whether their outcome is known.
724
- const tracks = [];
725
- /** Relays the leg (0.34.0: no browser note; the staged rows are the leg's record, D24). */
726
- const relayLeg = (prepared, peerId) => relaySwap({ wallet, authFetch: af, base }, prepared, peerId).catch((e) => ({ status: "unknown", id: swapIdOf(prepared), reason: errText(e) }));
727
- /**
728
- * 0.31.1: one retry of a leg refused `pool_spent` — re-quoted alone against the validator's
729
- * newest pool state (the engine's single-pool math, same side and amountIn), sent only while
730
- * the order's bound holds (filled legs + this one + the legs still out ≥ the plan's
731
- * resolvedMinAmountOut), rebuilt on the new pool output with the same staged rows (0.34.0: its
732
- * BEEF from `GET /pools/beef?lp=<the pool's LP key>`, constant along its continuations). Null:
733
- * the leg failed here (its error set); else the second relay's outcome.
734
- */
735
- const retryLeg = async (x) => {
736
- const { track, pool } = x;
737
- const result = track.result;
738
- const priced = computeSwap(pool, direction, x.leg.amountIn);
739
- const amountOut = priced?.amountOut ?? 0n;
740
- const others = tracks.filter((t) => t !== track).reduce((a, t) => a + (t.result.status === "filled" || !t.done ? t.amountOut : 0n), 0n);
741
- if (!priced || others + amountOut < p.plan.resolvedMinAmountOut) {
742
- result.error = `repriced below your minimum: ${formatAmount(amountOut, direction === "bsvToToken" ? p.dec : 0)} for this leg`;
743
- result.kind = "slippage";
744
- return null;
734
+ const leg = next.leg;
735
+ const row = pools.find((x) => x.outpoint === leg.outpoint);
736
+ const fresh = await readPoolsBeef(base, [row.poolId], freshFetch);
737
+ const now = fresh.pools.find((x) => x.poolId === row.poolId);
738
+ if (!now) {
739
+ excluded.add(row.outpoint);
740
+ continue;
745
741
  }
746
- let peerId = x.peerId;
747
- if (pool.validatorIdentityKey !== x.was.validatorIdentityKey) {
748
- const v = validatorStatus(pool.validatorIdentityKey, p.live);
749
- if (!v.live || !v.peerId) {
750
- result.error = "the pool's validator changed";
751
- return null;
752
- }
753
- peerId = v.peerId;
742
+ // Moved since the list was read: read the list again.
743
+ if (now.outpoint !== row.outpoint)
744
+ continue;
745
+ const poolOutput = poolOutputOf(fresh, leg.outpoint);
746
+ const peerId = row.validatorPeerId ?? answer.validators.find((v) => v.identity === row.validatorIdentityKey.toLowerCase())?.peerId;
747
+ if (!poolOutput || !peerId) {
748
+ excluded.add(row.outpoint);
749
+ continue;
754
750
  }
755
- const leg = { outpoint: pool.outpoint, amountIn: x.leg.amountIn, ...priced };
756
- try {
757
- const poolOutput = poolOutputOf(await readPoolsBeef(base, [x.was.lpPubKey], freshFetch), pool.outpoint);
758
- if (!poolOutput)
759
- throw new Error("the pool moved again: quote again");
760
- const common = { wallet, tokenId, meta: { sym: p.sym, dec: p.dec }, direction, leg, pool, poolOutput, commissionPkh, ...(x.tokens ? { tokenInputs: x.tokens } : {}), satsPerKb: feeRate };
761
- const planned = await planSwapLeg(common);
762
- const prepared = await prepareSwap({ ...common, satsInput: x.sats, ...(holdingBeef ? { holdingBeef } : {}), planned });
763
- track.amountOut = amountOut;
764
- result.repriced = true;
765
- return { o: await relayLeg(prepared, peerId), prepared };
751
+ const common = { wallet, tokenId, meta, direction, leg, pool: row, poolOutput, commissionPkh: answer.market.commissionPkh, satsPerKb: feeRate };
752
+ const inputs = selectLegInputs(common, await swapSatsHoldings(wallet), buy ? [] : await swapHoldings(wallet, tokenId));
753
+ if (!inputs) {
754
+ why = "fees";
755
+ break;
766
756
  }
767
- catch (e) {
768
- failLeg(result, e);
769
- return null;
757
+ const prepared = await prepareSwap({ ...common, inputs, ...(order ? { order } : {}), last: leg.amountIn === remaining, holdingBeef: await swapHoldingBeef(wallet) });
758
+ const at = { leg: outpointText(leg.outpoint), amountIn: leg.amountIn, amountOut: leg.amountOut };
759
+ progress({ phase: "relaying", ...at, filled: [...legs] });
760
+ let outcome = await relayLeg(wallet, af, base, prepared, peerId);
761
+ if (outcome.status === "unknown" || outcome.status === "timeout") {
762
+ // (a) The overlay: what the leg spends.
763
+ const spent = await readSpendsAll(swapBase, [outpointText(leg.outpoint), ...inputs.sats.map((r) => r.outpoint), ...inputs.tokens.map((r) => r.outpoint)], freshFetch).catch(() => []);
764
+ const ours = spent.find((x) => x.spentBy && x.outpoint !== outpointText(leg.outpoint));
765
+ if (ours) {
766
+ pending.push({ prepared, leg, poolId: row.poolId });
767
+ accountResolved(await resolve(wallet));
768
+ continue;
769
+ }
770
+ if (spent.some((x) => x.spentBy && x.outpoint === outpointText(leg.outpoint)))
771
+ continue;
772
+ // (b) Once more to the same validator.
773
+ outcome = await relayLeg(wallet, af, base, prepared, peerId);
774
+ if (outcome.status === "unknown" || outcome.status === "timeout") {
775
+ // (c) Race: the next leg to another pool with the same rows.
776
+ pending.push({ prepared, leg, poolId: row.poolId });
777
+ excluded.add(row.outpoint);
778
+ continue;
779
+ }
770
780
  }
771
- };
772
- for (const { r, poolOutput, planned, tokens, sats } of staged) {
773
- const { leg, result, pool, peerId } = r;
774
- if (tokensIn && !tokens) {
775
- result.error ??= `no ${p.sym} in the swap basket for exactly ${formatAmount(leg.amountIn, p.dec)}`;
781
+ if (outcome.status === "accepted") {
782
+ if (!outcome.completed.internalized) {
783
+ pending.push({ prepared, leg, poolId: row.poolId });
784
+ accountResolved(await resolve(wallet));
785
+ continue;
786
+ }
787
+ order ??= prepared.order;
788
+ budget -= 1;
789
+ filledIn += leg.amountIn;
790
+ filledOut += leg.amountOut;
791
+ legs.push({ poolOutpoint: at.leg, poolId: row.poolId, amountIn: leg.amountIn, amountOut: leg.amountOut, txid: outcome.txid });
776
792
  continue;
777
793
  }
778
- if (!sats) {
779
- result.error ??= `no sats in the swap basket for this leg`;
780
- continue;
794
+ if (outcome.status === "refused" && outcome.reason.startsWith("pool_spent")) {
795
+ // The relaying host took the validator's state in before it answered: the next read lists it.
796
+ progress({ phase: "repricing", ...at, filled: [...legs] });
781
797
  }
782
- try {
783
- const prepared = await prepareSwap({ wallet, tokenId, meta: { sym: p.sym, dec: p.dec }, direction, leg, pool, poolOutput, commissionPkh, ...(tokens ? { tokenInputs: tokens } : {}), satsInput: sats, ...(holdingBeef ? { holdingBeef } : {}), planned, satsPerKb: feeRate });
784
- const track = { result, amountOut: leg.amountOut, done: false };
785
- tracks.push(track);
786
- running.push((async () => {
787
- progress({ phase: "relaying", leg: result.poolOutpoint });
788
- let o = await relayLeg(prepared, peerId);
789
- let sentAs = prepared;
790
- // 0.31.1: refused `pool_spent` with the pool's newest state attached → repriced against
791
- // that state and sent once more (the staged `amm-swap` rows are still there).
792
- if (o.status === "refused" && o.reason.startsWith("pool_spent") && o.pool) {
793
- progress({ phase: "repricing", leg: result.poolOutpoint });
794
- const again = await retryLeg({ leg, track, was: pool, pool: o.pool, peerId, tokens, sats });
795
- if (!again) {
796
- track.done = true;
797
- return;
798
- }
799
- o = again.o;
800
- sentAs = again.prepared;
801
- }
802
- track.done = true;
803
- onLeg(result, o, sentAs, base, af);
804
- })());
798
+ else {
799
+ excluded.add(row.outpoint);
805
800
  }
806
- catch (e) {
807
- failLeg(result, e);
801
+ accountResolved(await resolve(wallet));
802
+ }
803
+ // 3. The end.
804
+ const out = { status: filledIn >= quote.request.amountIn ? "filled" : legs.length > 0 ? "partial" : "failed", stop: why, legs, totalIn: filledIn, totalOut: filledOut, unfilled: quote.request.amountIn - filledIn };
805
+ if (why !== "filled" && pending.length === 0) {
806
+ const left = (await swapSatsHoldings(wallet)).length + (await swapHoldings(wallet, tokenId)).length;
807
+ if (left > 0) {
808
+ progress({ phase: "returning", filled: [...legs] });
809
+ try {
810
+ const m = await moveBack({ wallet, base: swapBase, tokenId, meta: { sym: p.sym }, submit: submitAt(swapBase) });
811
+ out.returned = { txid: m.txid, tokens: m.amount };
812
+ }
813
+ catch (e) {
814
+ out.returned = { error: errText(e) };
815
+ }
808
816
  }
809
817
  }
810
- await Promise.all(running);
811
- emit();
812
- return swapResultOf(legs);
818
+ return out;
819
+ }
820
+ /** One relay of a prepared leg (0.34.0: no browser note); a thrown error is no answer. */
821
+ function relayLeg(wallet, af, base, prepared, peerId) {
822
+ return relaySwap({ wallet, authFetch: af, base }, prepared, peerId).catch((e) => ({ status: "unknown", id: swapIdOf(prepared), reason: errText(e) }));
813
823
  }
814
824
  /** A plain GET past the browser's short cache (`state` class: max-age 5): what a swap is about to spend must be read now. */
815
825
  const freshFetch = (url, init) => f(url, { ...init, cache: "no-cache" });
816
- /**
817
- * "Check again" (D15) for a leg with no answer yet: its record read ONCE (`GET
818
- * /@<token>/amm/swaps?id=`, plain), acted on (accepted: the payout taken in). → the leg's result
819
- * now (`poolOutpoint` as given).
820
- */
821
- async function checkSwapAgain(leg) {
822
- const { wallet } = needWallet();
823
- const id = leg.checkId;
824
- const c = id ? checks.get(id) : undefined;
825
- if (!id || !c)
826
- throw new Error("Nothing to check: this leg is not waiting on an answer here");
827
- const o = await checkAgain({ wallet, authFetch: c.af, base: c.base, fetch: freshFetch }, c.prepared, id);
828
- const next = { poolOutpoint: leg.poolOutpoint, status: "failed", ...(leg.repriced ? { repriced: true } : {}) };
829
- onLeg(next, o, c.prepared, c.base, c.af);
830
- if (next.status === "filled")
831
- emit();
832
- return next;
833
- }
834
826
  /** The connected wallet's spendable sats: the toolbox's balance special operation (`Wallet.balance()`'s call), its `totalOutputs`; a page may not read the admin-only `default` basket. */
835
827
  async function walletSats() {
836
828
  const { wallet } = needWallet();
@@ -853,15 +845,16 @@ function makeClient(opts, s) {
853
845
  const [poolRows, history, list] = await Promise.all([basketRows(wallet, POOLS_BASKET), historyPoolRows(wallet), enabledList().catch(() => [])]);
854
846
  const enabled = new Map(list.filter((t) => t.status === "enabled").map((t) => [t.tokenId, t]));
855
847
  const filed = new Set(poolRows.map((r) => r.outpoint.replace("_", ".")));
856
- // The rows by token, one per LP key (the pool's key for its life: a filed row and a history row of one pool are one).
848
+ // The rows by token, one per pool id (0.40.0: the deploy outpoint is the pool's id).
857
849
  const byToken = new Map();
858
850
  for (const row of positionRowsOf(mergePoolRows(history, poolRows))) {
859
851
  if (!row.lpPubKey || !enabled.has(row.tokenId))
860
852
  continue;
861
- const keys = byToken.get(row.tokenId) ?? new Map();
862
- if (!keys.has(row.lpPubKey))
863
- keys.set(row.lpPubKey, row);
864
- byToken.set(row.tokenId, keys);
853
+ const ids = byToken.get(row.tokenId) ?? new Map();
854
+ const poolId = row.deployOutpoint.replace(".", "_");
855
+ if (!ids.has(poolId))
856
+ ids.set(poolId, row);
857
+ byToken.set(row.tokenId, ids);
865
858
  }
866
859
  const out = [];
867
860
  const closed = [];
@@ -879,10 +872,12 @@ function makeClient(opts, s) {
879
872
  const meta = { sym: t?.sym ?? tokenId.slice(0, 8), dec: t?.dec ?? 0, ...(t?.icon ? { icon: t.icon } : {}) };
880
873
  const beating = new Set((await readPools(base, f).catch(() => null))?.validators.map((x) => x.identity) ?? []);
881
874
  for (const p of pools) {
882
- const row = keys.get(p.lpPubKey);
875
+ const row = keys.get(p.poolId);
883
876
  const fees = { feesEarnedSats: p.sats, feesEarnedTokens: p.tokens };
877
+ if (!row)
878
+ continue;
884
879
  if (p.status === "closed" || !p.outpoint || !p.current) {
885
- closed.push({ outpoint: row.deployOutpoint, tokenId, ...meta, sats: 0n, tokens: 0n, validator: { identityKey: row.validatorIdentity ?? "", live: false }, closed: true, ...fees });
880
+ closed.push({ outpoint: row.deployOutpoint, poolId: p.poolId, swaps: p.swaps, tokenId, ...meta, sats: 0n, tokens: 0n, validator: { identityKey: row.validatorIdentity ?? "", live: false }, closed: true, ...fees });
886
881
  continue;
887
882
  }
888
883
  const outpoint = p.outpoint.replace("_", ".");
@@ -892,6 +887,8 @@ function makeClient(opts, s) {
892
887
  await fileDeployRow(wallet, base, history, outpoint, meta);
893
888
  out.push({
894
889
  outpoint,
890
+ poolId: p.poolId,
891
+ swaps: p.swaps,
895
892
  tokenId,
896
893
  ...meta,
897
894
  sats: p.current.bsvReserve,
@@ -939,7 +936,7 @@ function makeClient(opts, s) {
939
936
  const t = (await instanceTerms(tokenId)).validator;
940
937
  if (!t)
941
938
  return { none: true };
942
- return { validator: { identityKey: t.identity, live: Boolean(t.peerId) }, isThisExchange: true, fees: { lpBps: t.lpFeeBps, validatorBps: t.validatorFeeBps, commissionBps: t.commissionBps } };
939
+ return { validator: { identityKey: t.identity, live: Boolean(t.peerId) }, isThisExchange: true, fees: { lpBps: t.lpFeeBps, validatorBps: t.validatorFeeBps, commissionBps: t.commissionBps }, minLegSats: t.minLegSats };
943
940
  }
944
941
  /**
945
942
  * Deploys a new position (src/lp/poolDeploy.ts, 0.13.0): this skein's
@@ -976,8 +973,8 @@ function makeClient(opts, s) {
976
973
  }
977
974
  /**
978
975
  * Closes a position (src/lp/close.ts): everything in the pool returns to the wallet (its own action
979
- * pays the miner fee). 0.34.0: the pool's current output and its BEEF by its LP key (`GET
980
- * /@<token>/amm/pools/beef?lp=<key>`, D2: the pool's id); the page's submit at the host root (D19).
976
+ * pays the miner fee). 0.40.0: the pool's current output and its BEEF by its pool id (`GET
977
+ * /@<token>/amm/pools/beef?pool=<deploy outpoint>`); the page's submit at the host root (D19).
981
978
  */
982
979
  async function closePosition(req) {
983
980
  const { wallet } = needWallet();
@@ -986,11 +983,11 @@ function makeClient(opts, s) {
986
983
  await myPositions();
987
984
  row = loaded.get(req.outpoint);
988
985
  }
989
- if (!row || !row.lpPubKey)
986
+ if (!row)
990
987
  throw new Error("No such position");
991
988
  const [meta, base] = await Promise.all([tokenMeta(), tokenBaseOf(row.tokenId)]);
992
989
  const m = meta.get(row.tokenId);
993
- const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.lpPubKey], freshFetch), req.outpoint);
990
+ const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.deployOutpoint.replace(".", "_")], freshFetch), req.outpoint);
994
991
  if (!poolOutput)
995
992
  throw new Error(`the pool is no longer at ${req.outpoint}: reload your positions`);
996
993
  const prepared = await prepareClose({
@@ -1011,8 +1008,8 @@ function makeClient(opts, s) {
1011
1008
  * `sats` deployed in ONE wallet createAction — the old pool the explicit input (as Close spends
1012
1009
  * it), the new one output 0 affirmed by the validator at its terms now (`amm.pool.affirm`, as a
1013
1010
  * deploy), the token difference through the wallet's module, the sats difference to or from the
1014
- * wallet's change. The old pool's output and BEEF by its LP key (`GET
1015
- * /@<token>/amm/pools/beef?lp=<key>`); the page's submit at the host root; the new pool's row
1011
+ * wallet's change. The old pool's output and BEEF by its pool id (`GET
1012
+ * /@<token>/amm/pools/beef?pool=<id>`); the page's submit at the host root; the new pool's row
1016
1013
  * filed in `amm-pools`.
1017
1014
  */
1018
1015
  async function rebalancePosition(req) {
@@ -1024,14 +1021,14 @@ function makeClient(opts, s) {
1024
1021
  await myPositions();
1025
1022
  row = loaded.get(req.outpoint);
1026
1023
  }
1027
- if (!row || !row.lpPubKey)
1024
+ if (!row)
1028
1025
  throw new Error("No such position");
1029
1026
  const [terms, held, meta, base] = await Promise.all([instanceTerms(row.tokenId), heldOutputs(wallet), tokenMeta(), tokenBaseOf(row.tokenId)]);
1030
1027
  const v = terms.validator;
1031
1028
  if (!v)
1032
1029
  throw new Error(NO_VALIDATOR);
1033
1030
  const m = meta.get(row.tokenId);
1034
- const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.lpPubKey], freshFetch), req.outpoint);
1031
+ const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.deployOutpoint.replace(".", "_")], freshFetch), req.outpoint);
1035
1032
  if (!poolOutput)
1036
1033
  throw new ClientError("stale_pool", `the pool is no longer at ${req.outpoint}: reload your positions`);
1037
1034
  const r = await rebalancePool({
@@ -1086,26 +1083,49 @@ function makeClient(opts, s) {
1086
1083
  balance,
1087
1084
  outputs: t?.valueOutputs ?? 0,
1088
1085
  inSwaps: swaps.get(tokenId)?.amount ?? 0n,
1089
- swapSats: swaps.get(tokenId)?.sats ?? 0,
1090
1086
  onExchange: registered.has(tokenId),
1091
1087
  };
1092
1088
  });
1093
- return out.filter((t) => t.balance > 0n || t.inSwaps > 0n || t.swapSats > 0).sort((a, b) => a.sym.localeCompare(b.sym) || a.tokenId.localeCompare(b.tokenId));
1089
+ return out.filter((t) => t.balance > 0n || t.inSwaps > 0n).sort((a, b) => a.sym.localeCompare(b.sym) || a.tokenId.localeCompare(b.tokenId));
1094
1090
  }
1095
1091
  /**
1096
- * The silent pass over the swap basket's staged legs (0.34.0, D18, D24; src/wallet/swapPayouts.ts):
1097
- * per token ONE `GET /spends?outpoint=` over the pools its rows were staged for, the spenders'
1098
- * transactions, this wallet's payouts taken in. Nothing shown; a payout taken in reloads the
1099
- * pages' data. Never throws.
1092
+ * The pass on load (0.40.0, src/wallet/swapPayouts.ts, src/market/activeSwap.ts): a labelled
1093
+ * staged token output the swap basket lacks filed from its action's description (its BEEF `GET
1094
+ * /@<token>/amm/tx?id=`), then the overlay pass over every `amm-swap` row (ONE `/amm/spends`, each
1095
+ * spender's `/tx`, this wallet's legs taken in). Nothing shown; a take-in reloads the pages' data.
1096
+ * Never throws. → the txids taken in.
1100
1097
  */
1101
1098
  async function settleSwaps() {
1102
1099
  if (!boundWallet)
1103
1100
  return [];
1104
- const taken = await settleStagedSwaps(boundWallet, (t) => tokenBase(t, swapBase), f);
1105
- if (taken.length > 0)
1101
+ let filed = false;
1102
+ try {
1103
+ for (const u of await unfiledStagedTokens(boundWallet)) {
1104
+ const beef = await readTx(tokenBase(u.tokenId, swapBase), u.txid, freshFetch).catch(() => null);
1105
+ if (beef && (await fileStagedToken(boundWallet, u, beef).catch(() => false)))
1106
+ filed = true;
1107
+ }
1108
+ }
1109
+ catch {
1110
+ /* the next load */
1111
+ }
1112
+ const taken = (await resolve(boundWallet)).map((t) => t.txid);
1113
+ if (filed || taken.length > 0)
1106
1114
  emit();
1107
1115
  return taken;
1108
1116
  }
1117
+ /** The sats in the swap basket (0.40.0: shown once, not per token). */
1118
+ async function swapBasketSats() {
1119
+ const { wallet } = needWallet();
1120
+ return swapSatsTotal(wallet);
1121
+ }
1122
+ /** "Move back" of the swap basket's sats alone (0.40.0, src/market/activeSwap.ts `moveBackSats`): one plain createAction, the sats rows its inputs, no outputs. */
1123
+ async function moveBackStagedSats() {
1124
+ const { wallet } = needWallet();
1125
+ const r = await moveBackSats(wallet);
1126
+ emit();
1127
+ return r;
1128
+ }
1109
1129
  /**
1110
1130
  * "Move back" (src/market/activeSwap.ts; 0.29.3: a module plan): every `amm-swap` row of the
1111
1131
  * token (and its staged sats) spent in one action whose one output the wallet's module files
@@ -1227,15 +1247,13 @@ function makeClient(opts, s) {
1227
1247
  }
1228
1248
  /**
1229
1249
  * Sets the default validator terms (0.17.0): root's `{fn: "defaults", args: {terms: {lpFeeBps,
1230
- * validatorFeeBps, commissionBps}}}` to the config instance's `<app>/admin` — a new token
1231
- * instance's initial terms (its own `amm/terms`, root's message to `/@<tokenId>`, wins once
1232
- * written). Refused here unless each fee is an integer 0 to 10 000.
1250
+ * validatorFeeBps, commissionBps, minLegSats?}}}` to the config instance's `<app>/admin` — a new
1251
+ * token instance's initial terms (its own `amm/terms`, root's message to `/@<tokenId>`, wins once
1252
+ * written). Refused here unless each fee is an integer 0 to 10 000 (0.40.0: and the minimum leg a
1253
+ * whole number of sats; 0 or absent is none and is not sent).
1233
1254
  */
1234
1255
  async function setDefaultTerms(t) {
1235
- for (const [k, v] of Object.entries(t))
1236
- if (!Number.isInteger(v) || v < 0 || v > 10_000)
1237
- throw new Error(`${k}: an integer, 0 to 10000 basis points`);
1238
- await sendDefaults({ terms: { lpFeeBps: t.lpBps, validatorFeeBps: t.validatorBps, commissionBps: t.commissionBps } });
1256
+ await sendDefaults({ terms: termsArgs(t) });
1239
1257
  }
1240
1258
  /** One token's validator terms as its instance holds them (0.35.0; P7 `GET /@<token>/amm/terms`, plain): the three fees, or null (a market only). */
1241
1259
  async function tokenTerms(tokenId) {
@@ -1243,19 +1261,15 @@ function makeClient(opts, s) {
1243
1261
  }
1244
1262
  /**
1245
1263
  * Sets one token's validator terms (0.35.0, open-exchange 0.12.0): root's `{fn: "terms", args:
1246
- * {tokenId, terms: {lpFeeBps, validatorFeeBps, commissionBps} | null}}` to the swap instance's
1264
+ * {tokenId, terms: {lpFeeBps, validatorFeeBps, commissionBps, minLegSats?} | null}}` to the swap instance's
1247
1265
  * `<app>/admin`; swap emits it as a fact and the host sends it to the token's instance as root's
1248
1266
  * `terms` (null: a market only). Nothing answers back: the token's `/terms` shows the result.
1249
1267
  * Refused here unless each fee is an integer 0 to 10 000.
1250
1268
  */
1251
1269
  async function setTokenTerms(tokenId, t) {
1252
- if (t)
1253
- for (const [k, v] of Object.entries(t))
1254
- if (!Number.isInteger(v) || v < 0 || v > 10_000)
1255
- throw new Error(`${k}: an integer, 0 to 10000 basis points`);
1270
+ const terms = t ? termsArgs(t) : null;
1256
1271
  const { signedFor } = needWallet();
1257
1272
  const me = await swapIdentity();
1258
- const terms = t ? { lpFeeBps: t.lpBps, validatorFeeBps: t.validatorBps, commissionBps: t.commissionBps } : null;
1259
1273
  await sendMessage(signedFor(swapBase), swapBase, me, `${app()}/admin`, { fn: "terms", args: { tokenId: sdkTokenId(tokenId), terms } });
1260
1274
  }
1261
1275
  /** Clears the default validator terms (0.17.0): `{fn: "defaults", args: {terms: null}}`; a new token instance is a market only. */
@@ -1311,11 +1325,15 @@ function makeClient(opts, s) {
1311
1325
  isRoot,
1312
1326
  claimExchange: guard(claimExchange),
1313
1327
  hostedTokens: guard(hostedTokens),
1314
- watchTokenPools,
1328
+ tokenPools: guard(tokenPools),
1329
+ watchPoolEvents,
1315
1330
  // history (0.39.0)
1316
1331
  trades: guard(trades),
1317
1332
  trade: guard(trade),
1318
1333
  candles: guard(candles),
1334
+ poolsAt: guard(poolsAt),
1335
+ poolHistory: guard(poolHistory),
1336
+ spendsAll: guard(spendsAll),
1319
1337
  stats: guard(stats),
1320
1338
  statsAll: guard(statsAll),
1321
1339
  holders: guard(holders),
@@ -1324,7 +1342,6 @@ function makeClient(opts, s) {
1324
1342
  // swap
1325
1343
  quoteSwap,
1326
1344
  executeSwap,
1327
- checkSwapAgain: guard(checkSwapAgain),
1328
1345
  walletSats: guard(walletSats),
1329
1346
  // liquidity
1330
1347
  myPositions: guard(myPositions),
@@ -1335,6 +1352,8 @@ function makeClient(opts, s) {
1335
1352
  // your tokens
1336
1353
  myTokens: guard(myTokens),
1337
1354
  settleSwaps: guard(settleSwaps),
1355
+ swapBasketSats: guard(swapBasketSats),
1356
+ moveBackStagedSats: guard(moveBackStagedSats),
1338
1357
  moveBackTokens: guard(moveBackTokens),
1339
1358
  sendTokens: guard(sendTokens),
1340
1359
  // settings