@dcasegr/amm-client 0.38.2 → 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
@@ -11,23 +11,27 @@
11
11
  *
12
12
  * - **The token list** (`GET <swap>/tokens`): read ONCE per client (D4; `enabledList`), again
13
13
  * only after its own enable or disable changed it.
14
- * - **Prices** (D6, D22): the host-wide pools route `GET <origin>/amm/pools` (`hostedTokens`,
15
- * `watchHostedPools`); a token's price is Σsats / Σtokens over its listed pools.
16
- * - **Swap** (D11–D16): the token's pools route `GET /@<token>/amm/pools` held as an event stream
17
- * (`watchTokenPools`): the pools whose validator is beating and whose commission meets the
18
- * market's minimum (the host's filter), the market's commission key hash; a quote is the
19
- * matching engine's plan over that list (no request); a swap reads ONE `GET
20
- * /@<token>/amm/pools/beef?lp=` for its legs (freshness, the pools' BEEF), then each leg's relay
21
- * request (`amm.swap.submit`, held by the host until the relay rests; 15 s expiry); a lost answer
22
- * is "no answer yet" with "Check again" (`GET /@<token>/amm/swaps?id=`, once).
23
- * - **Liquidity** (D9, D23): the wallet's pool rows and deploy history, then ONE `GET
24
- * /@<token>/amm/positions?lp=` per token; a deploy the history has but the basket lacks is
25
- * filed then; Close and Rebalance read their pool's BEEF by LP key (`/pools/beef?lp=`); the
26
- * deploy's terms `GET /@<token>/amm/terms`.
14
+ * - **Prices** (0.39.0, D3, D7): ONE read of the host-wide stats `GET <origin>/amm/stats`
15
+ * (`hostedTokens`): each token's price, liquidity (TVL) and pool count; no host-wide stream.
16
+ * - **History** (0.39.0, D1–D6): `trades`, `trade`, `candles`, `stats`, `statsAll`, `holders`,
17
+ * `positionsAll` — plain GETs of the history routes (H1–H6, src/lib/overlay.ts).
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`.
27
31
  * - **Your tokens** (D17, D18, D19, D24): the wallet's module and the swap basket; the token list
28
- * once and ONE discovery `GET /@discovery/1color/tokens?ids=` for held tokens the list lacks; a
29
- * silent pass over the swap basket's staged legs (src/wallet/swapPayouts.ts); Move back submits
30
- * 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.
31
35
  * - **Settings** (D20, D21): the claim card (`claimStatus`: swap's identity, the recipient of
32
36
  * root's messages), the token list once, discovery's list and ONE `?ids=` read; Enable's deploy
33
37
  * BEEF from the wallet's basket else `GET /@discovery/1color/<token>/deploy`.
@@ -39,18 +43,17 @@
39
43
  */
40
44
  import { Beef, Transaction } from "@bsv/sdk";
41
45
  import { send1Color } from "@1sat/actions";
42
- import { computeSwap } from "@dcasegr/amm-engine";
46
+ import { nextLeg, priceOf } from "@dcasegr/amm-engine";
43
47
  import { outpointText, parseOutpoint, parseTokenId, sdkTokenId, tokenIdText } from "./lib/tokenId.js";
44
48
  import { DEFAULT_FEE_RATE_SATS_PER_KB, appName, exchangeBase, tokenBase } from "./lib/config.js";
45
- import { ammLookupService, liveOfPools, parseAllPools, parsePools, parseTokenTopic, poolOutputOf, poolsUrl, readDiscoveryDeploy, readDiscoveryIds, readDiscoveryList, readPoolsBeef, readPools, readPositions, readTerms, 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";
46
50
  import { authFetchPerInstance, instanceBase, readDefaults, readDiscovery, readTokens, sendMessage } from "./lib/skein.js";
47
51
  import { depthOf } from "./market/prices.js";
48
- import { buildPlanRequest, goneFromLookup, quote as planQuote } from "./market/plan.js";
49
- import { validatorStatus } from "./market/view.js";
50
- import { planSwapLeg, prepareSwap, selectExactSatsInput, selectExactTokenInputs } from "./market/swapAction.js";
51
- import { moveBack, sendSatsToSwapBasket, sendToSwapBasket, swapHoldingBeef, swapHoldings, swapSatsHoldings, swapTotals } from "./market/activeSwap.js";
52
- import { checkAgain, relaySwap, swapIdOf } from "./market/swapFlow.js";
53
- 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";
54
57
  import { formatAmount } from "./lp/amounts.js";
55
58
  import { basketRows } from "./lp/wallet.js";
56
59
  import { buildInventory, decodeTokenRow, deployIcon } from "./lp/inventory.js";
@@ -60,7 +63,7 @@ import { rebalancePool } from "./lp/rebalance.js";
60
63
  import { historyPoolRows, mergePoolRows, positionRowsOf } from "./lp/positions.js";
61
64
  import { POOLS_BASKET, assetBasket, heldOutputs } from "./wallet/color.js";
62
65
  import { prepareClose, submitToOverlay } from "./lp/close.js";
63
- import { ClientError, errorKind, kindOfOutcome, toClientError } from "./errors.js";
66
+ import { ClientError, toClientError } from "./errors.js";
64
67
  /**
65
68
  * `amm.pool.terms`'s refusal when no terms are set (0.15.0, David Case
66
69
  * 2026-10-23: terms are a runtime record root sets; unset, a market only).
@@ -72,6 +75,7 @@ export const NO_TERMS_TEXT = "This exchange validates nothing: no terms are set.
72
75
  export function hasTerms(t) {
73
76
  return !!t && !("none" in t);
74
77
  }
78
+ export { parsePoolEventMessage } from "./lib/overlay.js";
75
79
  /** The metadata of the deploy output `vout` of an Atomic BEEF's subject; null when that output is no 1Color deploy. */
76
80
  function deployMetaOf(beef, vout) {
77
81
  const tx = Transaction.fromAtomicBEEF(beef);
@@ -85,11 +89,12 @@ function symOf(meta, tokenId) {
85
89
  return meta.get(tokenId)?.sym ?? tokenId.slice(0, 8);
86
90
  }
87
91
  /**
88
- * The hosted tokens (the enabled 1Color tokens at output 0), each priced from its rows of the
89
- * host-wide pools route (D6, D22: P1, the pools whose validator is beating): Σsats / Σtokens.
90
- * `rows` null: not read yet (no prices). Pure.
92
+ * The hosted tokens (the enabled 1Color tokens at output 0), each with its record of the host-wide
93
+ * stats (0.39.0, D3, D7: H4): the price (`price`, sats per whole token), the liquidity (`tvlSats`,
94
+ * `tvlTokens`) and the pool count. `stats` null: not read yet (no prices); a token the stats lack:
95
+ * no price, no pools. Pure.
91
96
  */
92
- export function hostedFrom(list, rows) {
97
+ export function hostedFrom(list, stats) {
93
98
  const out = [];
94
99
  for (const t of list) {
95
100
  if (t.status !== "enabled")
@@ -97,17 +102,18 @@ export function hostedFrom(list, rows) {
97
102
  const topic = parseTokenTopic(t.topic);
98
103
  if (!topic || topic.kind !== "native")
99
104
  continue;
100
- const mine = (rows ?? []).filter((r) => r.tokenId !== undefined && canonicalId(r.tokenId) === t.tokenId);
101
- const sats = mine.reduce((a, p) => a + p.bsvReserve, 0n);
102
- const tokens = mine.reduce((a, p) => a + p.tokenReserve, 0n);
105
+ const st = (stats ?? []).find((r) => canonicalId(r.tokenId) === t.tokenId);
106
+ const sats = st?.tvlSats ?? 0n;
107
+ const tokens = st?.tvlTokens ?? 0n;
108
+ const price = st?.price ?? null;
103
109
  out.push({
104
110
  tokenId: t.tokenId,
105
111
  sym: t.sym || t.tokenId.slice(0, 8),
106
112
  dec: t.dec,
107
113
  ...(t.icon ? { icon: t.icon } : {}),
108
- marginalPrice: tokens > 0n ? Number(sats) / (Number(tokens) / 10 ** t.dec) : null,
114
+ marginalPrice: price && price.tokens > 0n ? Number(price.sats) / (Number(price.tokens) / 10 ** t.dec) : null,
109
115
  depth: depthOf(sats),
110
- pools: mine.length,
116
+ pools: st?.pools ?? 0,
111
117
  reserves: { sats, tokens },
112
118
  });
113
119
  }
@@ -117,6 +123,7 @@ export function hostedFrom(list, rows) {
117
123
  export function poolViews(answer, tokenId) {
118
124
  return (answer?.pools ?? []).map((p) => ({
119
125
  outpoint: outpointText(p.outpoint),
126
+ poolId: p.poolId,
120
127
  tokenId,
121
128
  sats: p.bsvReserve,
122
129
  tokens: p.tokenReserve,
@@ -127,13 +134,29 @@ export function poolViews(answer, tokenId) {
127
134
  export function poolFees(p) {
128
135
  return { lpBps: Number(p.liquidityFeeBps), validatorBps: Number(p.validationFeeBps), commissionBps: Number(p.commissionBps ?? 0n) };
129
136
  }
130
- function swapResultOf(legs) {
131
- const filled = legs.filter((l) => l.status === "filled").length;
132
- 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;
133
151
  }
134
- /** A swap's result with one leg replaced (after "Check again"). */
135
- export function withLeg(r, leg) {
136
- 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;
137
160
  }
138
161
  /**
139
162
  * wallet-toolbox's `specOpWalletBalance` (`sdk.specOpWalletBalance`, @bsv/wallet-toolbox-client): the listOutputs "basket" of its
@@ -218,7 +241,17 @@ export function enableCandidates(listed, walletIds, onList) {
218
241
  }
219
242
  return out;
220
243
  }
221
- 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() });
222
255
  /**
223
256
  * The client of one exchange (D9): its reads over `origin` (src/lib/overlay.ts, src/lib/skein.ts),
224
257
  * the swap flow, the pool flows (deploy, close, rebalance, move back, positions) and the wallet
@@ -238,7 +271,6 @@ function makeClient(opts, s) {
238
271
  const boundWallet = opts.wallet ?? null;
239
272
  const signedFor = boundWallet ? (opts.signedFor ?? authFetchPerInstance(boundWallet, f)) : null;
240
273
  const planned = s.planned;
241
- const checks = s.checks;
242
274
  const loaded = s.loaded;
243
275
  function needWallet() {
244
276
  if (!boundWallet || !signedFor)
@@ -459,106 +491,154 @@ function makeClient(opts, s) {
459
491
  throw new Error("The claim was sent, but this wallet is not the admin: someone else may have claimed this exchange first.");
460
492
  return root;
461
493
  }
462
- /** The tokens this exchange hosts, priced: the token list (once) and ONE read of the host-wide pools route. No wallet needed. */
494
+ /** The tokens this exchange hosts, priced: the token list (once) and ONE read of the host-wide stats (0.39.0, H4). No wallet needed. */
463
495
  async function hostedTokens() {
464
- const [list, rows] = await Promise.all([enabledList(), readAllPools().catch(() => null)]);
465
- return hostedFrom(list, rows);
496
+ const [list, stats] = await Promise.all([enabledList(), statsAll().catch(() => null)]);
497
+ return hostedFrom(list, stats);
466
498
  }
467
- async function readAllPools() {
468
- const res = await f(poolsUrl(swapBase), { headers: { accept: "application/json" } });
469
- if (!res.ok)
470
- throw new Error(`GET ${poolsUrl(swapBase)}: ${res.status}`);
471
- return parseAllPools(await res.json());
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
+ }
503
+ /**
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.
507
+ */
508
+ function watchPoolEvents(tokenId, onEvent, onError) {
509
+ return watchFeed(eventsUrl(tokenId === "all" ? swapBase : tokenBase(tokenId, swapBase)), onEvent, onError ? { onError } : {});
510
+ }
511
+ // ---------------------------------------------------------------- history (0.39.0, H1–H6)
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). */
513
+ async function trades(tokenId, o = {}) {
514
+ return readTrades(tokenBase(tokenId, swapBase), o, ff);
472
515
  }
473
- /** The host-wide pools route held as an event stream (D22: one connection): each answer's rows to `onRows`; `onLive` the connection's state. → the unsubscribe. */
474
- function watchHostedPools(onRows, onLive) {
475
- return watchPools(poolsUrl(swapBase), parseAllPools, onRows, { onError: () => onLive?.(false), onOpen: () => onLive?.(true), fetch: f });
516
+ /** H1 with `txid`: one Swap's trade row, null when it has none. */
517
+ async function trade(tokenId, txid) {
518
+ return readTrade(tokenBase(tokenId, swapBase), txid, ff);
476
519
  }
477
- /** A token's pools route held as an event stream (D16): each answer to `onAnswer`. → the unsubscribe. */
478
- function watchTokenPools(tokenId, onAnswer, onError) {
479
- return watchPools(poolsUrl(tokenBase(tokenId, swapBase)), parsePools, onAnswer, { ...(onError ? { onError } : {}), fetch: f });
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). */
521
+ async function candles(tokenId, o) {
522
+ return readCandles(tokenBase(tokenId, swapBase), o, ff);
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
+ }
536
+ /** H3: a token's stats. */
537
+ async function stats(tokenId) {
538
+ return readStats(tokenBase(tokenId, swapBase), ff);
539
+ }
540
+ /** H4: every enabled token's stats (`ids`: those only, in that order). */
541
+ async function statsAll(o = {}) {
542
+ return readAllStats(swapBase, o.ids, o.fresh ? freshFetch : ff);
543
+ }
544
+ /** H6: a token's holders, largest first (`limit` 1–100, `skip`). */
545
+ async function holders(tokenId, o = {}) {
546
+ return readHolders(tokenBase(tokenId, swapBase), tokenId, o, ff);
547
+ }
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);
480
551
  }
481
552
  /**
482
553
  * A quote for one token (D12): the matching engine's plan over the pools the Swap page holds
483
554
  * (`held`: the token's pools route's answer — the host listed only pools whose validator is beating
484
- * and whose commission meets the market's minimum); no request. `meta`: the token's symbol and
485
- * 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.
486
560
  */
487
561
  function quoteSwap(req, held, meta) {
488
562
  if (req.amountIn <= 0n)
489
563
  throw new Error("Enter an amount");
490
- const pools = held?.pools ?? [];
491
- if (pools.length === 0)
564
+ if (!held || held.pools.length === 0)
492
565
  throw new Error("No pool to swap against");
566
+ const pools = poolsWithMinimums(held);
493
567
  const live = liveOfPools(pools);
494
568
  const dec = meta.dec;
495
569
  const buy = req.side === "buy";
496
- 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);
497
571
  if (!r.ok)
498
572
  throw new Error(r.error);
499
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}.`);
500
576
  const used = view.legs.filter((l) => l.amountIn > 0n);
501
577
  if (used.length === 0 || view.totalOut === 0n)
502
578
  throw new Error("The amount is too small to receive anything");
503
- const sumSats = pools.reduce((a, p) => a + p.bsvReserve, 0n);
504
- const sumTokens = pools.reduce((a, p) => a + p.tokenReserve, 0n);
505
- 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;
506
581
  const totalIn = used.reduce((a, l) => a + l.amountIn, 0n);
507
582
  const sats = buy ? totalIn : view.totalOut;
508
583
  const tokens = buy ? view.totalOut : totalIn;
509
584
  const effective = Number(sats) / (Number(tokens) / 10 ** dec);
510
- 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);
511
590
  const q = {
512
591
  request: req,
513
592
  legs: used.map((l) => ({
514
593
  poolOutpoint: outpointText(l.outpoint),
594
+ poolId: l.pool.poolId,
515
595
  amountIn: l.amountIn,
516
596
  amountOut: l.amountOut,
517
597
  shareBps: Number((l.amountIn * 10000n) / req.amountIn),
518
598
  reserves: { sats: l.pool.bsvReserve, tokens: l.pool.tokenReserve },
519
599
  validator: { identityKey: l.pool.validatorIdentityKey, live: l.validator.live },
600
+ fees: poolFees(l.pool),
520
601
  })),
521
602
  amountOut: view.totalOut,
522
603
  minAmountOut: view.minAmountOut,
523
604
  effectivePrice: effective,
524
605
  marginalPrice: marginal,
525
606
  impactBps: marginal ? Math.round((Math.abs(effective - marginal) / marginal) * 10_000) : 0,
526
- fees: { lpBps: Number(first.liquidityFeeBps), validatorBps: Number(first.validationFeeBps), commissionBps: Number(first.commissionBps ?? 0n) },
607
+ stage: { amount: req.amountIn, feeSats },
608
+ legMinerFeeSats: minerFee,
527
609
  };
528
- planned.set(q, { plan, pools, live, market: held.market, dec, sym: meta.sym });
610
+ planned.set(q, { plan, answer: held, dec, sym: meta.sym });
529
611
  return q;
530
612
  }
531
- /** A relay outcome on a leg's result (0.31.1's mapping; 0.34.0: no answer carries its id for "Check again"). */
532
- function onLeg(result, o, prepared, base, af) {
533
- delete result.checkId;
534
- if (o.status === "accepted") {
535
- result.status = "filled";
536
- result.txid = o.txid;
537
- delete result.error;
538
- delete result.kind;
539
- checks.delete(o.id);
540
- return;
541
- }
542
- result.status = "failed";
543
- const kind = kindOfOutcome(o);
544
- if (kind)
545
- result.kind = kind;
546
- const rest = o.status === "unknown" ? o.reason.replace(/^no answer yet:? ?/, "") : "";
547
- result.error = o.status === "refused" ? `refused: ${o.reason}` : o.status === "unknown" ? `no answer yet${rest ? `: ${rest}` : ""}` : o.status;
548
- if (o.status === "unknown") {
549
- result.checkId = o.id;
550
- checks.set(o.id, { prepared, base, af });
551
- }
552
- else if ("id" in o)
553
- 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) });
554
616
  }
555
617
  /**
556
- * Runs a quoted swap with the connected wallet (D14, D15): ONE `GET /@<token>/amm/pools/beef?lp=`
557
- * for the legs' LP keys — a leg whose key is missing or whose outpoint moved is gone since the
558
- * quote (as before 0.34.0) — the commission key hash and each validator's peer ID from the pools
559
- * the page holds; then one transaction per leg, relayed to the leg's validator (`amm.swap.submit`,
560
- * its answer the settled record), the payout taken in on acceptance. A planned pool gone stops the
561
- * 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.
562
642
  */
563
643
  async function executeSwap(quote, o = {}) {
564
644
  const progress = (x) => {
@@ -569,26 +649,26 @@ function makeClient(opts, s) {
569
649
  /* the caller's callback: not the swap's failure */
570
650
  }
571
651
  };
652
+ if (s.swapping)
653
+ throw new Error("A swap is already running: one at a time");
654
+ s.swapping = true;
572
655
  let result;
573
656
  try {
574
- result = await runSwap(quote, progress);
657
+ result = await runSwap(quote, progress, o.signal);
575
658
  }
576
659
  catch (e) {
577
660
  const err = toClientError(e);
578
661
  progress({ phase: "failed", error: err instanceof Error ? err : new Error(String(err)) });
579
662
  throw err;
580
663
  }
664
+ finally {
665
+ s.swapping = false;
666
+ }
667
+ emit();
581
668
  progress(result.status === "failed" ? { phase: "failed", result } : { phase: "done", result });
582
669
  return result;
583
670
  }
584
- /** A leg failed with `e`: its text, and its kind when it has one (D13). */
585
- function failLeg(result, e) {
586
- result.error = errText(e);
587
- const kind = errorKind(e);
588
- if (kind)
589
- result.kind = kind;
590
- }
591
- async function runSwap(quote, progress) {
671
+ async function runSwap(quote, progress, signal) {
592
672
  const { wallet, signedFor } = needWallet();
593
673
  const p = planned.get(quote);
594
674
  if (!p)
@@ -596,212 +676,153 @@ function makeClient(opts, s) {
596
676
  const tokenId = quote.request.tokenId;
597
677
  const base = tokenBase(tokenId, swapBase);
598
678
  const af = signedFor(base);
599
- const lpOf = (outpoint) => p.pools.find((x) => x.outpoint === outpoint)?.lpPubKey ?? "";
600
- const keys = [...new Set(p.plan.legs.map((l) => lpOf(l.outpoint)).filter((k) => k !== ""))];
601
- const fresh = await readPoolsBeef(base, keys, freshFetch);
602
- const gone = goneFromLookup(p.plan, fresh.pools);
603
- if (gone.length > 0 && !quote.request.allowPartial)
604
- throw new ClientError("pool_spent", `${gone.map(outpointText).join(", ")} ${gone.length === 1 ? "was" : "were"} spent since the quote: quote again`);
605
- const commissionPkh = p.market.commissionPkh;
606
- 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;
607
687
  const legs = [];
608
- const running = [];
609
- // The legs that can run: not spent since the quote, their validator live.
610
- const runnable = [];
611
- for (const leg of p.plan.legs) {
612
- const poolOutpoint = outpointText(leg.outpoint);
613
- if (gone.includes(leg.outpoint)) {
614
- legs.push({ poolOutpoint, status: "failed", error: "spent since the quote", kind: "pool_spent" });
615
- continue;
616
- }
617
- const pool = p.pools.find((x) => x.outpoint === leg.outpoint);
618
- const validator = validatorStatus(pool.validatorIdentityKey, p.live);
619
- if (!validator.live || !validator.peerId) {
620
- legs.push({ poolOutpoint, status: "failed", error: "the pool's validator is no longer live" });
621
- 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 });
622
704
  }
623
- const result = { poolOutpoint, status: "failed" };
624
- legs.push(result);
625
- 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) });
626
717
  }
627
- // 0.29.0: every leg spends only `amm-swap` outputs — tokens in, outputs carrying exactly its
628
- // amount in (0.12.0); and one sats output of exactly the leg's sats. What the basket does not
629
- // cover is staged by ONE send (tokens missing: the module send, the sats with it; else a plain
630
- // sats send), then each leg is built from the basket.
631
- const tokensIn = direction === "tokenToBsv";
632
- if (runnable.length > 0)
633
- progress({ phase: "staging" });
634
- let tokenCandidates = tokensIn && runnable.length > 0 ? await swapHoldings(wallet, tokenId) : [];
635
- let satsCandidates = runnable.length > 0 ? await swapSatsHoldings(wallet) : [];
636
- const staged = [];
637
- for (const r of runnable) {
638
- try {
639
- const poolOutput = poolOutputOf(fresh, r.leg.outpoint);
640
- if (!poolOutput)
641
- throw new Error("the pools read has no transaction for this pool");
642
- let tokens = null;
643
- if (tokensIn) {
644
- tokens = selectExactTokenInputs(tokenCandidates, r.leg.amountIn);
645
- if (tokens)
646
- tokenCandidates = tokenCandidates.filter((c) => !tokens.includes(c));
647
- }
648
- // A leg whose tokens are still to be staged gets one output of 1 sat (sendToSwapBasket).
649
- 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 });
650
- const sats = selectExactSatsInput(satsCandidates, planned.funding.satoshis);
651
- if (sats)
652
- satsCandidates = satsCandidates.filter((c) => c !== sats);
653
- 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;
654
724
  }
655
- catch (e) {
656
- 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;
657
733
  }
658
- }
659
- const shortTokens = staged.filter((x) => tokensIn && !x.tokens);
660
- const shortSats = staged.filter((x) => !x.sats);
661
- let sent;
662
- if (shortTokens.length > 0) {
663
- 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));
664
- sent = s.tx;
665
- const why = s.errors.length ? ` (${s.errors.join("; ")})` : "";
666
- shortTokens.forEach((x, n) => {
667
- const h = s.holdings[n];
668
- if (h)
669
- x.tokens = [h];
670
- else
671
- x.r.result.error = `the swap basket send has no output for this leg${why}`;
672
- });
673
- shortSats.forEach((x, n) => {
674
- const h = s.sats[n];
675
- if (h)
676
- x.sats = h;
677
- else
678
- x.r.result.error ??= `the swap basket send has no sats output for this leg${why}`;
679
- });
680
- }
681
- else if (shortSats.length > 0) {
682
- const s = await sendSatsToSwapBasket(wallet, tokenId, shortSats.map((x) => ({ satoshis: x.planned.funding.satoshis, pool: x.r.leg.outpoint })), { sym: p.sym });
683
- sent = s.tx;
684
- const why = s.errors.length ? ` (${s.errors.join("; ")})` : "";
685
- shortSats.forEach((x, n) => {
686
- const h = s.sats[n];
687
- if (h)
688
- x.sats = h;
689
- else
690
- x.r.result.error = `the swap basket send has no sats output for this leg${why}`;
691
- });
692
- }
693
- const holdingBeef = staged.length > 0 ? await swapHoldingBeef(wallet, sent) : undefined;
694
- // The legs sent to the relay: their expected amountOut, and whether their outcome is known.
695
- const tracks = [];
696
- /** Relays the leg (0.34.0: no browser note; the staged rows are the leg's record, D24). */
697
- const relayLeg = (prepared, peerId) => relaySwap({ wallet, authFetch: af, base }, prepared, peerId).catch((e) => ({ status: "unknown", id: swapIdOf(prepared), reason: errText(e) }));
698
- /**
699
- * 0.31.1: one retry of a leg refused `pool_spent` — re-quoted alone against the validator's
700
- * newest pool state (the engine's single-pool math, same side and amountIn), sent only while
701
- * the order's bound holds (filled legs + this one + the legs still out ≥ the plan's
702
- * resolvedMinAmountOut), rebuilt on the new pool output with the same staged rows (0.34.0: its
703
- * BEEF from `GET /pools/beef?lp=<the pool's LP key>`, constant along its continuations). Null:
704
- * the leg failed here (its error set); else the second relay's outcome.
705
- */
706
- const retryLeg = async (x) => {
707
- const { track, pool } = x;
708
- const result = track.result;
709
- const priced = computeSwap(pool, direction, x.leg.amountIn);
710
- const amountOut = priced?.amountOut ?? 0n;
711
- const others = tracks.filter((t) => t !== track).reduce((a, t) => a + (t.result.status === "filled" || !t.done ? t.amountOut : 0n), 0n);
712
- if (!priced || others + amountOut < p.plan.resolvedMinAmountOut) {
713
- result.error = `repriced below your minimum: ${formatAmount(amountOut, direction === "bsvToToken" ? p.dec : 0)} for this leg`;
714
- result.kind = "slippage";
715
- 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;
716
741
  }
717
- let peerId = x.peerId;
718
- if (pool.validatorIdentityKey !== x.was.validatorIdentityKey) {
719
- const v = validatorStatus(pool.validatorIdentityKey, p.live);
720
- if (!v.live || !v.peerId) {
721
- result.error = "the pool's validator changed";
722
- return null;
723
- }
724
- 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;
725
750
  }
726
- const leg = { outpoint: pool.outpoint, amountIn: x.leg.amountIn, ...priced };
727
- try {
728
- const poolOutput = poolOutputOf(await readPoolsBeef(base, [x.was.lpPubKey], freshFetch), pool.outpoint);
729
- if (!poolOutput)
730
- throw new Error("the pool moved again: quote again");
731
- const common = { wallet, tokenId, meta: { sym: p.sym, dec: p.dec }, direction, leg, pool, poolOutput, commissionPkh, ...(x.tokens ? { tokenInputs: x.tokens } : {}), satsPerKb: feeRate };
732
- const planned = await planSwapLeg(common);
733
- const prepared = await prepareSwap({ ...common, satsInput: x.sats, ...(holdingBeef ? { holdingBeef } : {}), planned });
734
- track.amountOut = amountOut;
735
- result.repriced = true;
736
- 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;
737
756
  }
738
- catch (e) {
739
- failLeg(result, e);
740
- 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
+ }
741
780
  }
742
- };
743
- for (const { r, poolOutput, planned, tokens, sats } of staged) {
744
- const { leg, result, pool, peerId } = r;
745
- if (tokensIn && !tokens) {
746
- 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 });
747
792
  continue;
748
793
  }
749
- if (!sats) {
750
- result.error ??= `no sats in the swap basket for this leg`;
751
- 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] });
752
797
  }
753
- try {
754
- 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 });
755
- const track = { result, amountOut: leg.amountOut, done: false };
756
- tracks.push(track);
757
- running.push((async () => {
758
- progress({ phase: "relaying", leg: result.poolOutpoint });
759
- let o = await relayLeg(prepared, peerId);
760
- let sentAs = prepared;
761
- // 0.31.1: refused `pool_spent` with the pool's newest state attached → repriced against
762
- // that state and sent once more (the staged `amm-swap` rows are still there).
763
- if (o.status === "refused" && o.reason.startsWith("pool_spent") && o.pool) {
764
- progress({ phase: "repricing", leg: result.poolOutpoint });
765
- const again = await retryLeg({ leg, track, was: pool, pool: o.pool, peerId, tokens, sats });
766
- if (!again) {
767
- track.done = true;
768
- return;
769
- }
770
- o = again.o;
771
- sentAs = again.prepared;
772
- }
773
- track.done = true;
774
- onLeg(result, o, sentAs, base, af);
775
- })());
798
+ else {
799
+ excluded.add(row.outpoint);
776
800
  }
777
- catch (e) {
778
- 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
+ }
779
816
  }
780
817
  }
781
- await Promise.all(running);
782
- emit();
783
- 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) }));
784
823
  }
785
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. */
786
825
  const freshFetch = (url, init) => f(url, { ...init, cache: "no-cache" });
787
- /**
788
- * "Check again" (D15) for a leg with no answer yet: its record read ONCE (`GET
789
- * /@<token>/amm/swaps?id=`, plain), acted on (accepted: the payout taken in). → the leg's result
790
- * now (`poolOutpoint` as given).
791
- */
792
- async function checkSwapAgain(leg) {
793
- const { wallet } = needWallet();
794
- const id = leg.checkId;
795
- const c = id ? checks.get(id) : undefined;
796
- if (!id || !c)
797
- throw new Error("Nothing to check: this leg is not waiting on an answer here");
798
- const o = await checkAgain({ wallet, authFetch: c.af, base: c.base, fetch: freshFetch }, c.prepared, id);
799
- const next = { poolOutpoint: leg.poolOutpoint, status: "failed", ...(leg.repriced ? { repriced: true } : {}) };
800
- onLeg(next, o, c.prepared, c.base, c.af);
801
- if (next.status === "filled")
802
- emit();
803
- return next;
804
- }
805
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. */
806
827
  async function walletSats() {
807
828
  const { wallet } = needWallet();
@@ -824,15 +845,16 @@ function makeClient(opts, s) {
824
845
  const [poolRows, history, list] = await Promise.all([basketRows(wallet, POOLS_BASKET), historyPoolRows(wallet), enabledList().catch(() => [])]);
825
846
  const enabled = new Map(list.filter((t) => t.status === "enabled").map((t) => [t.tokenId, t]));
826
847
  const filed = new Set(poolRows.map((r) => r.outpoint.replace("_", ".")));
827
- // 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).
828
849
  const byToken = new Map();
829
850
  for (const row of positionRowsOf(mergePoolRows(history, poolRows))) {
830
851
  if (!row.lpPubKey || !enabled.has(row.tokenId))
831
852
  continue;
832
- const keys = byToken.get(row.tokenId) ?? new Map();
833
- if (!keys.has(row.lpPubKey))
834
- keys.set(row.lpPubKey, row);
835
- 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);
836
858
  }
837
859
  const out = [];
838
860
  const closed = [];
@@ -850,10 +872,12 @@ function makeClient(opts, s) {
850
872
  const meta = { sym: t?.sym ?? tokenId.slice(0, 8), dec: t?.dec ?? 0, ...(t?.icon ? { icon: t.icon } : {}) };
851
873
  const beating = new Set((await readPools(base, f).catch(() => null))?.validators.map((x) => x.identity) ?? []);
852
874
  for (const p of pools) {
853
- const row = keys.get(p.lpPubKey);
875
+ const row = keys.get(p.poolId);
854
876
  const fees = { feesEarnedSats: p.sats, feesEarnedTokens: p.tokens };
877
+ if (!row)
878
+ continue;
855
879
  if (p.status === "closed" || !p.outpoint || !p.current) {
856
- 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 });
857
881
  continue;
858
882
  }
859
883
  const outpoint = p.outpoint.replace("_", ".");
@@ -863,6 +887,8 @@ function makeClient(opts, s) {
863
887
  await fileDeployRow(wallet, base, history, outpoint, meta);
864
888
  out.push({
865
889
  outpoint,
890
+ poolId: p.poolId,
891
+ swaps: p.swaps,
866
892
  tokenId,
867
893
  ...meta,
868
894
  sats: p.current.bsvReserve,
@@ -910,7 +936,7 @@ function makeClient(opts, s) {
910
936
  const t = (await instanceTerms(tokenId)).validator;
911
937
  if (!t)
912
938
  return { none: true };
913
- 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 };
914
940
  }
915
941
  /**
916
942
  * Deploys a new position (src/lp/poolDeploy.ts, 0.13.0): this skein's
@@ -947,8 +973,8 @@ function makeClient(opts, s) {
947
973
  }
948
974
  /**
949
975
  * Closes a position (src/lp/close.ts): everything in the pool returns to the wallet (its own action
950
- * pays the miner fee). 0.34.0: the pool's current output and its BEEF by its LP key (`GET
951
- * /@<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).
952
978
  */
953
979
  async function closePosition(req) {
954
980
  const { wallet } = needWallet();
@@ -957,11 +983,11 @@ function makeClient(opts, s) {
957
983
  await myPositions();
958
984
  row = loaded.get(req.outpoint);
959
985
  }
960
- if (!row || !row.lpPubKey)
986
+ if (!row)
961
987
  throw new Error("No such position");
962
988
  const [meta, base] = await Promise.all([tokenMeta(), tokenBaseOf(row.tokenId)]);
963
989
  const m = meta.get(row.tokenId);
964
- const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.lpPubKey], freshFetch), req.outpoint);
990
+ const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.deployOutpoint.replace(".", "_")], freshFetch), req.outpoint);
965
991
  if (!poolOutput)
966
992
  throw new Error(`the pool is no longer at ${req.outpoint}: reload your positions`);
967
993
  const prepared = await prepareClose({
@@ -982,8 +1008,8 @@ function makeClient(opts, s) {
982
1008
  * `sats` deployed in ONE wallet createAction — the old pool the explicit input (as Close spends
983
1009
  * it), the new one output 0 affirmed by the validator at its terms now (`amm.pool.affirm`, as a
984
1010
  * deploy), the token difference through the wallet's module, the sats difference to or from the
985
- * wallet's change. The old pool's output and BEEF by its LP key (`GET
986
- * /@<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
987
1013
  * filed in `amm-pools`.
988
1014
  */
989
1015
  async function rebalancePosition(req) {
@@ -995,14 +1021,14 @@ function makeClient(opts, s) {
995
1021
  await myPositions();
996
1022
  row = loaded.get(req.outpoint);
997
1023
  }
998
- if (!row || !row.lpPubKey)
1024
+ if (!row)
999
1025
  throw new Error("No such position");
1000
1026
  const [terms, held, meta, base] = await Promise.all([instanceTerms(row.tokenId), heldOutputs(wallet), tokenMeta(), tokenBaseOf(row.tokenId)]);
1001
1027
  const v = terms.validator;
1002
1028
  if (!v)
1003
1029
  throw new Error(NO_VALIDATOR);
1004
1030
  const m = meta.get(row.tokenId);
1005
- const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.lpPubKey], freshFetch), req.outpoint);
1031
+ const poolOutput = poolOutputOf(await readPoolsBeef(base, [row.deployOutpoint.replace(".", "_")], freshFetch), req.outpoint);
1006
1032
  if (!poolOutput)
1007
1033
  throw new ClientError("stale_pool", `the pool is no longer at ${req.outpoint}: reload your positions`);
1008
1034
  const r = await rebalancePool({
@@ -1057,26 +1083,49 @@ function makeClient(opts, s) {
1057
1083
  balance,
1058
1084
  outputs: t?.valueOutputs ?? 0,
1059
1085
  inSwaps: swaps.get(tokenId)?.amount ?? 0n,
1060
- swapSats: swaps.get(tokenId)?.sats ?? 0,
1061
1086
  onExchange: registered.has(tokenId),
1062
1087
  };
1063
1088
  });
1064
- 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));
1065
1090
  }
1066
1091
  /**
1067
- * The silent pass over the swap basket's staged legs (0.34.0, D18, D24; src/wallet/swapPayouts.ts):
1068
- * per token ONE `GET /spends?outpoint=` over the pools its rows were staged for, the spenders'
1069
- * transactions, this wallet's payouts taken in. Nothing shown; a payout taken in reloads the
1070
- * 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.
1071
1097
  */
1072
1098
  async function settleSwaps() {
1073
1099
  if (!boundWallet)
1074
1100
  return [];
1075
- const taken = await settleStagedSwaps(boundWallet, (t) => tokenBase(t, swapBase), f);
1076
- 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)
1077
1114
  emit();
1078
1115
  return taken;
1079
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
+ }
1080
1129
  /**
1081
1130
  * "Move back" (src/market/activeSwap.ts; 0.29.3: a module plan): every `amm-swap` row of the
1082
1131
  * token (and its staged sats) spent in one action whose one output the wallet's module files
@@ -1198,15 +1247,13 @@ function makeClient(opts, s) {
1198
1247
  }
1199
1248
  /**
1200
1249
  * Sets the default validator terms (0.17.0): root's `{fn: "defaults", args: {terms: {lpFeeBps,
1201
- * validatorFeeBps, commissionBps}}}` to the config instance's `<app>/admin` — a new token
1202
- * instance's initial terms (its own `amm/terms`, root's message to `/@<tokenId>`, wins once
1203
- * 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).
1204
1254
  */
1205
1255
  async function setDefaultTerms(t) {
1206
- for (const [k, v] of Object.entries(t))
1207
- if (!Number.isInteger(v) || v < 0 || v > 10_000)
1208
- throw new Error(`${k}: an integer, 0 to 10000 basis points`);
1209
- await sendDefaults({ terms: { lpFeeBps: t.lpBps, validatorFeeBps: t.validatorBps, commissionBps: t.commissionBps } });
1256
+ await sendDefaults({ terms: termsArgs(t) });
1210
1257
  }
1211
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). */
1212
1259
  async function tokenTerms(tokenId) {
@@ -1214,19 +1261,15 @@ function makeClient(opts, s) {
1214
1261
  }
1215
1262
  /**
1216
1263
  * Sets one token's validator terms (0.35.0, open-exchange 0.12.0): root's `{fn: "terms", args:
1217
- * {tokenId, terms: {lpFeeBps, validatorFeeBps, commissionBps} | null}}` to the swap instance's
1264
+ * {tokenId, terms: {lpFeeBps, validatorFeeBps, commissionBps, minLegSats?} | null}}` to the swap instance's
1218
1265
  * `<app>/admin`; swap emits it as a fact and the host sends it to the token's instance as root's
1219
1266
  * `terms` (null: a market only). Nothing answers back: the token's `/terms` shows the result.
1220
1267
  * Refused here unless each fee is an integer 0 to 10 000.
1221
1268
  */
1222
1269
  async function setTokenTerms(tokenId, t) {
1223
- if (t)
1224
- for (const [k, v] of Object.entries(t))
1225
- if (!Number.isInteger(v) || v < 0 || v > 10_000)
1226
- throw new Error(`${k}: an integer, 0 to 10000 basis points`);
1270
+ const terms = t ? termsArgs(t) : null;
1227
1271
  const { signedFor } = needWallet();
1228
1272
  const me = await swapIdentity();
1229
- const terms = t ? { lpFeeBps: t.lpBps, validatorFeeBps: t.validatorBps, commissionBps: t.commissionBps } : null;
1230
1273
  await sendMessage(signedFor(swapBase), swapBase, me, `${app()}/admin`, { fn: "terms", args: { tokenId: sdkTokenId(tokenId), terms } });
1231
1274
  }
1232
1275
  /** Clears the default validator terms (0.17.0): `{fn: "defaults", args: {terms: null}}`; a new token instance is a market only. */
@@ -1282,13 +1325,23 @@ function makeClient(opts, s) {
1282
1325
  isRoot,
1283
1326
  claimExchange: guard(claimExchange),
1284
1327
  hostedTokens: guard(hostedTokens),
1285
- watchHostedPools,
1286
- watchTokenPools,
1328
+ tokenPools: guard(tokenPools),
1329
+ watchPoolEvents,
1330
+ // history (0.39.0)
1331
+ trades: guard(trades),
1332
+ trade: guard(trade),
1333
+ candles: guard(candles),
1334
+ poolsAt: guard(poolsAt),
1335
+ poolHistory: guard(poolHistory),
1336
+ spendsAll: guard(spendsAll),
1337
+ stats: guard(stats),
1338
+ statsAll: guard(statsAll),
1339
+ holders: guard(holders),
1340
+ positionsAll: guard(positionsAll),
1287
1341
  registeredTokens: guard(registeredTokens),
1288
1342
  // swap
1289
1343
  quoteSwap,
1290
1344
  executeSwap,
1291
- checkSwapAgain: guard(checkSwapAgain),
1292
1345
  walletSats: guard(walletSats),
1293
1346
  // liquidity
1294
1347
  myPositions: guard(myPositions),
@@ -1299,6 +1352,8 @@ function makeClient(opts, s) {
1299
1352
  // your tokens
1300
1353
  myTokens: guard(myTokens),
1301
1354
  settleSwaps: guard(settleSwaps),
1355
+ swapBasketSats: guard(swapBasketSats),
1356
+ moveBackStagedSats: guard(moveBackStagedSats),
1302
1357
  moveBackTokens: guard(moveBackTokens),
1303
1358
  sendTokens: guard(sendTokens),
1304
1359
  // settings