@dcasegr/amm-client 0.38.1 → 0.39.0

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/README.md CHANGED
@@ -39,9 +39,16 @@ exchange with another wallet, keeping what was read and quoted.
39
39
 
40
40
  The client (each an async function unless noted):
41
41
 
42
- - the exchange: `enabledList`, `registeredTokens`, `tokenBaseOf`, `hostedTokens`,
43
- `watchHostedPools` / `watchTokenPools` (event streams, sync, return the unsubscribe),
42
+ - the exchange: `enabledList`, `registeredTokens`, `tokenBaseOf`, `hostedTokens` (the token list
43
+ and ONE `GET /amm/stats`), `watchTokenPools` (the token's pools route as an event stream: the
44
+ whole answer, then each `change` — `swap`, `deploy`, `close`, `rescind` — applied to it, one it
45
+ cannot apply read again once; sync, returns the unsubscribe; 0.39.0: no host-wide stream),
44
46
  `claimStatus`, `isRoot`, `claimExchange`, `discoveryBase` (sync), `onDataChange` (sync);
47
+ - history (0.39.0, plain GETs): `trades(tokenId, {from?, to?, limit?, before?, lp?})` →
48
+ `{trades, next}`, `trade(tokenId, txid)` → a `Trade` or null, `candles(tokenId, {tf, from, to})`,
49
+ `stats(tokenId)`, `statsAll({ids?})`, `holders(tokenId, {limit?, skip?})` → `{count, rows}`,
50
+ `positionsAll(lpKeys)` (every enabled token's positions); amounts bigints, prices `{sats,
51
+ tokens}` pairs of bigints (base units both);
45
52
  - swap: `quoteSwap(request, poolsAnswer, {sym, dec})` (sync: the engine's plan over the pools the
46
53
  token's pools stream gave), `executeSwap(quote, {onProgress})`, `checkSwapAgain(leg)`,
47
54
  `walletSats`;
@@ -54,7 +61,7 @@ The client (each an async function unless noted):
54
61
  `setDefaultTerms`, `clearDefaultTerms`, `setDefaultMarket`, `clearDefaultMarket`, `tokenTerms`,
55
62
  `setTokenTerms`, `discoveryState`, `setDiscovery`.
56
63
 
57
- Pure helpers beside it: `hostedFrom`, `poolViews`, `poolFees`, `withLeg`, `hasTerms`,
64
+ Pure helpers beside it: `hostedFrom`, `applyPoolChange`, `parseChange`, `poolViews`, `poolFees`, `withLeg`, `hasTerms`,
58
65
  `enableCandidates`, `topicOf`, `lookupOf`, `ammLookupOf`, `walletDeployBeef`, `exchangeBase`,
59
66
  `tokenBase`; the types (`Quote`, `SwapResult`, `LegResult`, `Position`, `WalletToken`, …).
60
67
 
package/dist/client.d.ts CHANGED
@@ -11,10 +11,13 @@
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.
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).
16
18
  * - **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
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
18
21
  * market's minimum (the host's filter), the market's commission key hash; a quote is the
19
22
  * matching engine's plan over that list (no request); a swap reads ONE `GET
20
23
  * /@<token>/amm/pools/beef?lp=` for its legs (freshness, the pools' BEEF), then each leg's relay
@@ -40,7 +43,7 @@
40
43
  import { type WalletInterface } from "@bsv/sdk";
41
44
  import { type Send1ColorResult } from "@1sat/actions";
42
45
  import { type PoolState, type Plan } from "@dcasegr/amm-engine";
43
- import { type LiveAnswer, type PoolRow, type PoolsAnswer, type SignedFetch } from "./lib/overlay.js";
46
+ import { type Candle, type CandleTf, type Holders, type TokenPosition, type TokenStats, type Trade, type TradesPage, type LiveAnswer, type PoolRow, type PoolsAnswer, type SignedFetch } from "./lib/overlay.js";
44
47
  import { type Defaults, type Discovery, type EnabledToken, type ListedToken } from "./lib/skein.js";
45
48
  import type { AuthFetchLike } from "./market/relay.js";
46
49
  import { type PreparedSwap } from "./market/swapAction.js";
@@ -262,7 +265,9 @@ export interface RegisteredToken {
262
265
  icon?: string;
263
266
  status: EnabledToken["status"];
264
267
  }
265
- export type { EnabledToken, Defaults, Discovery, PoolRow, PoolsAnswer };
268
+ export type { EnabledToken, Defaults, Discovery, PoolRow, PoolsAnswer, Candle, CandleTf, Holders, TokenPosition, TokenStats, Trade, TradesPage };
269
+ export type { PoolChange, Price } from "./lib/overlay.js";
270
+ export { applyPoolChange, parseChange } from "./lib/overlay.js";
266
271
  /** A token's symbol, decimals and icon, read off its deploy output. */
267
272
  export interface DeployMeta {
268
273
  sym?: string;
@@ -287,11 +292,12 @@ export interface ClaimStatus {
287
292
  claimant?: string;
288
293
  }
289
294
  /**
290
- * The hosted tokens (the enabled 1Color tokens at output 0), each priced from its rows of the
291
- * host-wide pools route (D6, D22: P1, the pools whose validator is beating): Σsats / Σtokens.
292
- * `rows` null: not read yet (no prices). Pure.
295
+ * The hosted tokens (the enabled 1Color tokens at output 0), each with its record of the host-wide
296
+ * stats (0.39.0, D3, D7: H4): the price (`price`, sats per whole token), the liquidity (`tvlSats`,
297
+ * `tvlTokens`) and the pool count. `stats` null: not read yet (no prices); a token the stats lack:
298
+ * no price, no pools. Pure.
293
299
  */
294
- export declare function hostedFrom(list: EnabledToken[], rows: PoolRow[] | null): HostedToken[];
300
+ export declare function hostedFrom(list: EnabledToken[], stats: TokenStats[] | null): HostedToken[];
295
301
  /** The pools the Swap page lists (D11, D13): the token's pools route's rows, every validator live (the host lists no other). */
296
302
  export declare function poolViews(answer: PoolsAnswer | null, tokenId: TokenId): PoolView[];
297
303
  /** The fees a pool's own record names (D13: the Swap page's fee line), basis points. */
@@ -444,8 +450,29 @@ declare function makeClient(opts: ClientOptions, s: Shared): {
444
450
  isRoot: (identityKey: string | null) => Promise<boolean>;
445
451
  claimExchange: () => Promise<boolean>;
446
452
  hostedTokens: () => Promise<HostedToken[]>;
447
- watchHostedPools: (onRows: (rows: PoolRow[]) => void, onLive?: (live: boolean) => void) => () => void;
448
453
  watchTokenPools: (tokenId: TokenId, onAnswer: (a: PoolsAnswer) => void, onError?: (e: unknown) => void) => () => void;
454
+ trades: (tokenId: string, o?: {
455
+ from?: number;
456
+ to?: number;
457
+ limit?: number;
458
+ before?: string;
459
+ lp?: string;
460
+ } | undefined) => Promise<TradesPage>;
461
+ trade: (tokenId: string, txid: string) => Promise<Trade | null>;
462
+ candles: (tokenId: string, o: {
463
+ tf: CandleTf;
464
+ from: number;
465
+ to: number;
466
+ }) => Promise<Candle[]>;
467
+ stats: (tokenId: string) => Promise<TokenStats>;
468
+ statsAll: (o?: {
469
+ ids?: TokenId[];
470
+ } | undefined) => Promise<TokenStats[]>;
471
+ holders: (tokenId: string, o?: {
472
+ limit?: number;
473
+ skip?: number;
474
+ } | undefined) => Promise<Holders>;
475
+ positionsAll: (lpKeys: string[]) => Promise<TokenPosition[]>;
449
476
  registeredTokens: () => Promise<RegisteredToken[]>;
450
477
  quoteSwap: (req: QuoteRequest, held: PoolsAnswer | null, meta: {
451
478
  sym: string;
@@ -493,8 +520,29 @@ declare function makeClient(opts: ClientOptions, s: Shared): {
493
520
  isRoot: (identityKey: string | null) => Promise<boolean>;
494
521
  claimExchange: () => Promise<boolean>;
495
522
  hostedTokens: () => Promise<HostedToken[]>;
496
- watchHostedPools: (onRows: (rows: PoolRow[]) => void, onLive?: (live: boolean) => void) => () => void;
497
523
  watchTokenPools: (tokenId: TokenId, onAnswer: (a: PoolsAnswer) => void, onError?: (e: unknown) => void) => () => void;
524
+ trades: (tokenId: string, o?: {
525
+ from?: number;
526
+ to?: number;
527
+ limit?: number;
528
+ before?: string;
529
+ lp?: string;
530
+ } | undefined) => Promise<TradesPage>;
531
+ trade: (tokenId: string, txid: string) => Promise<Trade | null>;
532
+ candles: (tokenId: string, o: {
533
+ tf: CandleTf;
534
+ from: number;
535
+ to: number;
536
+ }) => Promise<Candle[]>;
537
+ stats: (tokenId: string) => Promise<TokenStats>;
538
+ statsAll: (o?: {
539
+ ids?: TokenId[];
540
+ } | undefined) => Promise<TokenStats[]>;
541
+ holders: (tokenId: string, o?: {
542
+ limit?: number;
543
+ skip?: number;
544
+ } | undefined) => Promise<Holders>;
545
+ positionsAll: (lpKeys: string[]) => Promise<TokenPosition[]>;
498
546
  registeredTokens: () => Promise<RegisteredToken[]>;
499
547
  quoteSwap: (req: QuoteRequest, held: PoolsAnswer | null, meta: {
500
548
  sym: string;
package/dist/client.js CHANGED
@@ -11,10 +11,13 @@
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.
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).
16
18
  * - **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
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
18
21
  * market's minimum (the host's filter), the market's commission key hash; a quote is the
19
22
  * matching engine's plan over that list (no request); a swap reads ONE `GET
20
23
  * /@<token>/amm/pools/beef?lp=` for its legs (freshness, the pools' BEEF), then each leg's relay
@@ -42,7 +45,7 @@ import { send1Color } from "@1sat/actions";
42
45
  import { computeSwap } from "@dcasegr/amm-engine";
43
46
  import { outpointText, parseOutpoint, parseTokenId, sdkTokenId, tokenIdText } from "./lib/tokenId.js";
44
47
  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";
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";
46
49
  import { authFetchPerInstance, instanceBase, readDefaults, readDiscovery, readTokens, sendMessage } from "./lib/skein.js";
47
50
  import { depthOf } from "./market/prices.js";
48
51
  import { buildPlanRequest, goneFromLookup, quote as planQuote } from "./market/plan.js";
@@ -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 { applyPoolChange, parseChange } 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
  }
@@ -459,24 +465,47 @@ function makeClient(opts, s) {
459
465
  throw new Error("The claim was sent, but this wallet is not the admin: someone else may have claimed this exchange first.");
460
466
  return root;
461
467
  }
462
- /** The tokens this exchange hosts, priced: the token list (once) and ONE read of the host-wide pools route. No wallet needed. */
468
+ /** 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
469
  async function hostedTokens() {
464
- const [list, rows] = await Promise.all([enabledList(), readAllPools().catch(() => null)]);
465
- return hostedFrom(list, rows);
470
+ const [list, stats] = await Promise.all([enabledList(), statsAll().catch(() => null)]);
471
+ return hostedFrom(list, stats);
466
472
  }
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());
473
+ /**
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.
477
+ */
478
+ function watchTokenPools(tokenId, onAnswer, onError) {
479
+ return watchPools(poolsUrl(tokenBase(tokenId, swapBase)), parsePools, onAnswer, { ...(onError ? { onError } : {}), fetch: f, change: applyPoolChange });
472
480
  }
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 });
481
+ // ---------------------------------------------------------------- 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). */
483
+ async function trades(tokenId, o = {}) {
484
+ return readTrades(tokenBase(tokenId, swapBase), o, ff);
476
485
  }
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 });
486
+ /** H1 with `txid`: one Swap's trade row, null when it has none. */
487
+ async function trade(tokenId, txid) {
488
+ return readTrade(tokenBase(tokenId, swapBase), txid, ff);
489
+ }
490
+ /** H2: a token's candles, one per non-empty bucket of `tf` over `from ≤ at < to` (ms). */
491
+ async function candles(tokenId, o) {
492
+ return readCandles(tokenBase(tokenId, swapBase), o, ff);
493
+ }
494
+ /** H3: a token's stats. */
495
+ async function stats(tokenId) {
496
+ return readStats(tokenBase(tokenId, swapBase), ff);
497
+ }
498
+ /** H4: every enabled token's stats (`ids`: those only, in that order). */
499
+ async function statsAll(o = {}) {
500
+ return readAllStats(swapBase, o.ids, ff);
501
+ }
502
+ /** H6: a token's holders, largest first (`limit` 1–100, `skip`). */
503
+ async function holders(tokenId, o = {}) {
504
+ return readHolders(tokenBase(tokenId, swapBase), tokenId, o, ff);
505
+ }
506
+ /** H5: the LP keys' positions on every enabled token. */
507
+ async function positionsAll(lpKeys) {
508
+ return readAllPositions(swapBase, lpKeys, ff);
480
509
  }
481
510
  /**
482
511
  * A quote for one token (D12): the matching engine's plan over the pools the Swap page holds
@@ -1282,8 +1311,15 @@ function makeClient(opts, s) {
1282
1311
  isRoot,
1283
1312
  claimExchange: guard(claimExchange),
1284
1313
  hostedTokens: guard(hostedTokens),
1285
- watchHostedPools,
1286
1314
  watchTokenPools,
1315
+ // history (0.39.0)
1316
+ trades: guard(trades),
1317
+ trade: guard(trade),
1318
+ candles: guard(candles),
1319
+ stats: guard(stats),
1320
+ statsAll: guard(statsAll),
1321
+ holders: guard(holders),
1322
+ positionsAll: guard(positionsAll),
1287
1323
  registeredTokens: guard(registeredTokens),
1288
1324
  // swap
1289
1325
  quoteSwap,
@@ -100,16 +100,22 @@ export interface EventSourceLike {
100
100
  }
101
101
  export type EventSourceCtor = new (url: string) => EventSourceLike;
102
102
  /**
103
- * Holds a pools route (P1 or P2) as an event stream (D16, D22): `GET <url>` with `Accept:
104
- * text/event-stream` (the browser's EventSource sends it), the event `pools` carrying the answer,
105
- * first the current one, then again each time it changes; `onAnswer` gets each. Without an
106
- * EventSource (an old browser, a test): ONE plain GET, no poll. → the unsubscribe.
103
+ * Holds a pools route as an event stream (D16; 0.39.0, D7): `GET <url>` with `Accept:
104
+ * text/event-stream` (the browser's EventSource sends it). The event `pools` carries the whole
105
+ * answer (first the current one; again when the host cannot say what changed): it replaces the held
106
+ * answer. With `change` (P2), the event `change` carries one pool change: `change(held, x)` applies
107
+ * it to the held answer (null: it cannot); one it cannot apply (no answer held yet, an unknown
108
+ * kind, a row it names not held) → the route is read again ONCE (a plain GET) and that answer is
109
+ * held (changes arriving while it is read are dropped: the answer read is the newer state). Each
110
+ * new held answer goes to `onAnswer`. Without an EventSource (an old browser, a test): ONE plain
111
+ * GET, no poll. → the unsubscribe.
107
112
  */
108
113
  export declare function watchPools<T>(url: string, parse: (x: unknown) => T, onAnswer: (a: T) => void, o?: {
109
114
  onError?: (e: unknown) => void;
110
115
  onOpen?: () => void;
111
116
  EventSource?: EventSourceCtor | null;
112
117
  fetch?: FetchFn;
118
+ change?: (held: T, x: unknown) => T | null;
113
119
  }): () => void;
114
120
  /** P3's answer: the LP keys' current pools and one BEEF of their transactions. */
115
121
  export interface PoolsBeef {
@@ -187,4 +193,123 @@ export declare function readDiscoveryIds(discovery: string, ids: string[], fetch
187
193
  export declare function readDiscoveryList(discovery: string, fetchFn?: FetchFn): Promise<DiscoveryRow[]>;
188
194
  /** P16: `GET <discovery>/<token>/deploy`: the deploy's BEEF; null when discovery does not hold it. */
189
195
  export declare function readDiscoveryDeploy(discovery: string, tokenId: string, fetchFn?: FetchFn): Promise<number[] | null>;
196
+ /** A price, exact (H1–H4): `sats` satoshis per `tokens` token base units (divide in the caller). */
197
+ export interface Price {
198
+ sats: bigint;
199
+ tokens: bigint;
200
+ }
201
+ /** A Swap's trade row (H1; the `swap` change's `trade`): amounts in base units, fees in the input asset, the pool's reserves after it. */
202
+ export interface Trade {
203
+ txid: string;
204
+ lpPubKey: string;
205
+ /** `buy`: sats in, tokens out; `sell`: tokens in. */
206
+ side: "buy" | "sell";
207
+ amountIn: bigint;
208
+ amountOut: bigint;
209
+ feeLp: bigint;
210
+ feeValidator: bigint;
211
+ feeCommission: bigint;
212
+ bsvReserve: bigint;
213
+ tokenReserve: bigint;
214
+ /** The admitting step's time, ms. */
215
+ at: number;
216
+ /** The block height where the overlay has the transaction placed, else null. */
217
+ height: number | null;
218
+ }
219
+ /** H1's page: the trades newest first, `next` the cursor of the page after (null: the last page). */
220
+ export interface TradesPage {
221
+ trades: Trade[];
222
+ next: string | null;
223
+ }
224
+ /** One non-empty bucket (H2): its start (ms), the first, highest, lowest and last trade's prices, each side's amounts summed, the trade count. */
225
+ export interface Candle {
226
+ t: number;
227
+ o: Price;
228
+ h: Price;
229
+ l: Price;
230
+ c: Price;
231
+ volumeSats: bigint;
232
+ volumeTokens: bigint;
233
+ n: number;
234
+ }
235
+ /** A candle's resolution (H2). */
236
+ export type CandleTf = "1m" | "5m" | "15m" | "1h" | "4h" | "1d";
237
+ /** A token at the read's time (H3, H4): the changes in bps (null without a trade at or before the window's start). */
238
+ export interface TokenStats {
239
+ tokenId: string;
240
+ /** The last trade's, else the deepest live pool's, else null. */
241
+ price: Price | null;
242
+ change1h: number | null;
243
+ change24h: number | null;
244
+ volume24hSats: bigint;
245
+ volume24hTokens: bigint;
246
+ buys24h: number;
247
+ sells24h: number;
248
+ lastTradeAt: number | null;
249
+ tvlSats: bigint;
250
+ tvlTokens: bigint;
251
+ pools: number;
252
+ }
253
+ /** A token's holders (H6): `count` the addresses with a balance, `rows` largest balance first. */
254
+ export interface Holders {
255
+ count: number;
256
+ rows: {
257
+ address: string;
258
+ balance: bigint;
259
+ }[];
260
+ }
261
+ /** One LP key's position on one token (H5): P4's record with its token. */
262
+ export interface TokenPosition extends LpPool {
263
+ tokenId: string;
264
+ }
265
+ /** One pool change on P2's stream (event `change`, D7). */
266
+ export interface PoolChange {
267
+ kind: "deploy" | "swap" | "close" | "rescind";
268
+ /** The new pool output (deploy, swap) or the one gone (close, rescind). */
269
+ outpoint: string;
270
+ /** deploy, swap: the pool's row. */
271
+ pool?: PoolRow;
272
+ /** swap: its trade row. */
273
+ trade?: Trade;
274
+ }
275
+ /** A trade row, or null when it is not one. */
276
+ export declare function toTrade(x: unknown): Trade | null;
277
+ /** A stats record (H3), or null when it is not one. */
278
+ export declare function toStats(x: unknown): TokenStats | null;
279
+ /** A `change` event's data, or null when it is not one this client knows. */
280
+ export declare function parseChange(x: unknown): PoolChange | null;
281
+ /**
282
+ * One `change` applied to a held P2 answer (D7) → the new answer, or null when it cannot be applied
283
+ * (the caller reads the route again): `swap` — the row of `trade.lpPubKey` replaced by `pool` (its
284
+ * outpoint moved); `deploy` — `pool` added; `close` / `rescind` — the row at `outpoint` removed.
285
+ * The market and the validators are kept (a change of either comes as a whole `pools` answer).
286
+ */
287
+ export declare function applyPoolChange(held: PoolsAnswer, x: unknown): PoolsAnswer | null;
288
+ /** H1: `GET <base>/trades?from=&to=&limit=&before=&lp=` → the page, newest first. */
289
+ export declare function readTrades(base: string, o?: {
290
+ from?: number;
291
+ to?: number;
292
+ limit?: number;
293
+ before?: string;
294
+ lp?: string;
295
+ }, fetchFn?: FetchFn): Promise<TradesPage>;
296
+ /** H1 with `txid`: one Swap's trade row; null when the transaction has none (404). */
297
+ export declare function readTrade(base: string, txid: string, fetchFn?: FetchFn): Promise<Trade | null>;
298
+ /** H2: `GET <base>/candles?tf=&from=&to=` → one candle per non-empty bucket, oldest first. */
299
+ export declare function readCandles(base: string, o: {
300
+ tf: CandleTf;
301
+ from: number;
302
+ to: number;
303
+ }, fetchFn?: FetchFn): Promise<Candle[]>;
304
+ /** H3: `GET <base>/stats`. */
305
+ export declare function readStats(base: string, fetchFn?: FetchFn): Promise<TokenStats>;
306
+ /** H4: `GET <swap base>/stats` (`ids`: those enabled tokens only, in that order) → every enabled token's stats. */
307
+ export declare function readAllStats(swapBase: string, ids?: string[], fetchFn?: FetchFn): Promise<TokenStats[]>;
308
+ /** H6: `GET <token base>/color/<token>/holders?limit=&skip=` → the count and the rows, largest first. */
309
+ export declare function readHolders(base: string, tokenId: string, o?: {
310
+ limit?: number;
311
+ skip?: number;
312
+ }, fetchFn?: FetchFn): Promise<Holders>;
313
+ /** H5: `GET <swap base>/positions?lp=k1,k2` → every enabled token's rows for the keys, each with its token. */
314
+ export declare function readAllPositions(swapBase: string, lpPubKeys: string[], fetchFn?: FetchFn): Promise<TokenPosition[]>;
190
315
  export {};
@@ -25,6 +25,16 @@
25
25
  * | P15 | `GET /@discovery/1color/<token>/icon` | the icon's bytes |
26
26
  * | P16 | `GET /@discovery/1color/<token>/deploy` | the deploy's BEEF (bytes) |
27
27
  * | P17 | `GET <origin>/.well-known/skein`, `/skeins?key=`, `/amm/admin`, `/amm/defaults`, `/amm/discovery` | as before |
28
+ * | H1 | `GET /@<token>/amm/trades?from=&to=&limit=&before=&lp=` (or `?txid=`) | `{trades: [Trade], next}` newest first (one txid: the row) |
29
+ * | H2 | `GET /@<token>/amm/candles?tf=&from=&to=` | `[{t, o, h, l, c, volumeSats, volumeTokens, n}]`, one per non-empty bucket |
30
+ * | H3 | `GET /@<token>/amm/stats` | `{tokenId, price, change1h, change24h, volume24hSats, volume24hTokens, buys24h, sells24h, lastTradeAt, tvlSats, tvlTokens, pools}` |
31
+ * | H4 | `GET <origin>/amm/stats?ids=` | `[H3]`, every enabled token's (the host's) |
32
+ * | H5 | `GET <origin>/amm/positions?lp=` | `[{tokenId, …P4 row}]`, every enabled token's (the host's) |
33
+ * | H6 | `GET /@<token>/amm/color/<token>/holders?limit=&skip=` | `{count, rows: [{address, balance}]}` largest first |
34
+ *
35
+ * 0.39.0 (agent-env plans/history-api-2026-11-05.md D7): P2's stream sends the whole answer once
36
+ * (`pools`), then ONE `change` per pool change (`{kind, outpoint, pool?, trade?}`); the host-wide
37
+ * stream is gone (P1, H4 and H5 are plain reads).
28
38
  *
29
39
  * Writes stay what they were: `POST <origin>/submit` (BRC-22, plain), `/call` and `/sendMessage`
30
40
  * (signed, the wallet's BRC-104 client).
@@ -140,10 +150,15 @@ function eventSourceCtor() {
140
150
  return typeof g.EventSource === "function" ? g.EventSource : null;
141
151
  }
142
152
  /**
143
- * Holds a pools route (P1 or P2) as an event stream (D16, D22): `GET <url>` with `Accept:
144
- * text/event-stream` (the browser's EventSource sends it), the event `pools` carrying the answer,
145
- * first the current one, then again each time it changes; `onAnswer` gets each. Without an
146
- * EventSource (an old browser, a test): ONE plain GET, no poll. → the unsubscribe.
153
+ * Holds a pools route as an event stream (D16; 0.39.0, D7): `GET <url>` with `Accept:
154
+ * text/event-stream` (the browser's EventSource sends it). The event `pools` carries the whole
155
+ * answer (first the current one; again when the host cannot say what changed): it replaces the held
156
+ * answer. With `change` (P2), the event `change` carries one pool change: `change(held, x)` applies
157
+ * it to the held answer (null: it cannot); one it cannot apply (no answer held yet, an unknown
158
+ * kind, a row it names not held) → the route is read again ONCE (a plain GET) and that answer is
159
+ * held (changes arriving while it is read are dropped: the answer read is the newer state). Each
160
+ * new held answer goes to `onAnswer`. Without an EventSource (an old browser, a test): ONE plain
161
+ * GET, no poll. → the unsubscribe.
147
162
  */
148
163
  export function watchPools(url, parse, onAnswer, o = {}) {
149
164
  const ES = o.EventSource === undefined ? eventSourceCtor() : o.EventSource;
@@ -160,17 +175,53 @@ export function watchPools(url, parse, onAnswer, o = {}) {
160
175
  stopped = true;
161
176
  };
162
177
  }
178
+ let held = null;
179
+ let rereading = false;
180
+ const hold = (a) => {
181
+ held = a;
182
+ onAnswer(a);
183
+ };
184
+ const reread = () => {
185
+ rereading = true;
186
+ getJson(url, o.fetch).then((x) => {
187
+ rereading = false;
188
+ if (!stopped)
189
+ hold(parse(x));
190
+ }, (e) => {
191
+ rereading = false;
192
+ if (!stopped)
193
+ o.onError?.(e);
194
+ });
195
+ };
163
196
  const es = new ES(url);
164
197
  es.addEventListener("pools", (e) => {
165
198
  if (stopped)
166
199
  return;
167
200
  try {
168
- onAnswer(parse(JSON.parse(String(e.data))));
201
+ hold(parse(JSON.parse(String(e.data))));
169
202
  }
170
203
  catch (err) {
171
204
  o.onError?.(err);
172
205
  }
173
206
  });
207
+ const apply = o.change;
208
+ if (apply) {
209
+ es.addEventListener("change", (e) => {
210
+ if (stopped || rereading)
211
+ return;
212
+ let next = null;
213
+ try {
214
+ next = held === null ? null : apply(held, JSON.parse(String(e.data)));
215
+ }
216
+ catch {
217
+ next = null;
218
+ }
219
+ if (next === null)
220
+ reread();
221
+ else
222
+ hold(next);
223
+ });
224
+ }
174
225
  es.onopen = () => o.onOpen?.();
175
226
  es.onerror = (e) => o.onError?.(e);
176
227
  return () => {
@@ -300,3 +351,172 @@ export async function readDiscoveryList(discovery, fetchFn) {
300
351
  export async function readDiscoveryDeploy(discovery, tokenId, fetchFn) {
301
352
  return getBytes(`${trim(discovery)}/${encodeURIComponent(tokenId)}/deploy`, fetchFn);
302
353
  }
354
+ const intOrNull = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
355
+ const int = (x) => intOrNull(x) ?? 0;
356
+ function toPrice(x) {
357
+ const p = x;
358
+ if (!p || typeof p !== "object" || p.sats === undefined || p.tokens === undefined)
359
+ return null;
360
+ return { sats: wireBig(p.sats), tokens: wireBig(p.tokens) };
361
+ }
362
+ /** A trade row, or null when it is not one. */
363
+ export function toTrade(x) {
364
+ const r = x;
365
+ if (!r || typeof r !== "object" || typeof r.txid !== "string" || typeof r.lpPubKey !== "string" || (r.side !== "buy" && r.side !== "sell"))
366
+ return null;
367
+ return {
368
+ txid: r.txid.toLowerCase(),
369
+ lpPubKey: r.lpPubKey.toLowerCase(),
370
+ side: r.side,
371
+ amountIn: wireBig(r.amountIn),
372
+ amountOut: wireBig(r.amountOut),
373
+ feeLp: wireBig(r.feeLp),
374
+ feeValidator: wireBig(r.feeValidator),
375
+ feeCommission: wireBig(r.feeCommission),
376
+ bsvReserve: wireBig(r.bsvReserve),
377
+ tokenReserve: wireBig(r.tokenReserve),
378
+ at: int(r.at),
379
+ height: intOrNull(r.height),
380
+ };
381
+ }
382
+ function toCandle(x) {
383
+ const r = x;
384
+ if (!r || typeof r !== "object")
385
+ return null;
386
+ const [o, h, l, c] = [toPrice(r.o), toPrice(r.h), toPrice(r.l), toPrice(r.c)];
387
+ if (!o || !h || !l || !c || typeof r.t !== "number")
388
+ return null;
389
+ return { t: r.t, o, h, l, c, volumeSats: wireBig(r.volumeSats), volumeTokens: wireBig(r.volumeTokens), n: int(r.n) };
390
+ }
391
+ /** A stats record (H3), or null when it is not one. */
392
+ export function toStats(x) {
393
+ const r = x;
394
+ if (!r || typeof r !== "object" || typeof r.tokenId !== "string")
395
+ return null;
396
+ return {
397
+ tokenId: r.tokenId,
398
+ price: toPrice(r.price),
399
+ change1h: intOrNull(r.change1h),
400
+ change24h: intOrNull(r.change24h),
401
+ volume24hSats: wireBig(r.volume24hSats),
402
+ volume24hTokens: wireBig(r.volume24hTokens),
403
+ buys24h: int(r.buys24h),
404
+ sells24h: int(r.sells24h),
405
+ lastTradeAt: intOrNull(r.lastTradeAt),
406
+ tvlSats: wireBig(r.tvlSats),
407
+ tvlTokens: wireBig(r.tvlTokens),
408
+ pools: int(r.pools),
409
+ };
410
+ }
411
+ /** A `change` event's data, or null when it is not one this client knows. */
412
+ export function parseChange(x) {
413
+ const r = x;
414
+ if (!r || typeof r !== "object" || typeof r.outpoint !== "string")
415
+ return null;
416
+ if (r.kind !== "deploy" && r.kind !== "swap" && r.kind !== "close" && r.kind !== "rescind")
417
+ return null;
418
+ const pool = r.pool === undefined ? null : toPoolRow(r.pool);
419
+ const trade = r.trade === undefined ? null : toTrade(r.trade);
420
+ return { kind: r.kind, outpoint: r.outpoint, ...(pool ? { pool } : {}), ...(trade ? { trade } : {}) };
421
+ }
422
+ const sameOutpoint = (a, b) => a.toLowerCase().replace(".", "_") === b.toLowerCase().replace(".", "_");
423
+ /**
424
+ * One `change` applied to a held P2 answer (D7) → the new answer, or null when it cannot be applied
425
+ * (the caller reads the route again): `swap` — the row of `trade.lpPubKey` replaced by `pool` (its
426
+ * outpoint moved); `deploy` — `pool` added; `close` / `rescind` — the row at `outpoint` removed.
427
+ * The market and the validators are kept (a change of either comes as a whole `pools` answer).
428
+ */
429
+ export function applyPoolChange(held, x) {
430
+ const ch = parseChange(x);
431
+ if (!ch)
432
+ return null;
433
+ switch (ch.kind) {
434
+ case "swap": {
435
+ if (!ch.pool || !ch.trade)
436
+ return null;
437
+ const i = held.pools.findIndex((p) => p.lpPubKey === ch.trade.lpPubKey);
438
+ if (i < 0)
439
+ return null;
440
+ const pools = held.pools.slice();
441
+ pools[i] = ch.pool;
442
+ return { ...held, pools };
443
+ }
444
+ case "deploy":
445
+ if (!ch.pool)
446
+ return null;
447
+ return { ...held, pools: [...held.pools, ch.pool] };
448
+ case "close":
449
+ case "rescind": {
450
+ const pools = held.pools.filter((p) => !sameOutpoint(p.outpoint, ch.outpoint));
451
+ if (pools.length === held.pools.length)
452
+ return null;
453
+ return { ...held, pools };
454
+ }
455
+ }
456
+ }
457
+ const query = (q) => {
458
+ const kv = Object.entries(q).filter(([, x]) => x !== undefined).map(([k, x]) => `${k}=${encodeURIComponent(String(x))}`);
459
+ return kv.length ? `?${kv.join("&")}` : "";
460
+ };
461
+ /** H1: `GET <base>/trades?from=&to=&limit=&before=&lp=` → the page, newest first. */
462
+ export async function readTrades(base, o = {}, fetchFn) {
463
+ const a = (await getJson(`${trim(base)}/trades${query({ from: o.from, to: o.to, limit: o.limit, before: o.before, lp: o.lp })}`, fetchFn));
464
+ return {
465
+ trades: (Array.isArray(a?.trades) ? a.trades : []).map(toTrade).filter((t) => t !== null),
466
+ next: typeof a?.next === "string" ? a.next : null,
467
+ };
468
+ }
469
+ /** H1 with `txid`: one Swap's trade row; null when the transaction has none (404). */
470
+ export async function readTrade(base, txid, fetchFn) {
471
+ const url = `${trim(base)}/trades?txid=${encodeURIComponent(txid)}`;
472
+ const res = await (fetchFn ?? plain)(url, { headers: { accept: "application/json" } });
473
+ if (res.status === 404)
474
+ return null;
475
+ if (!res.ok)
476
+ throw new Error(`GET ${url}: ${res.status} ${(await res.text()).slice(0, 200)}`);
477
+ return toTrade(await res.json());
478
+ }
479
+ /** H2: `GET <base>/candles?tf=&from=&to=` → one candle per non-empty bucket, oldest first. */
480
+ export async function readCandles(base, o, fetchFn) {
481
+ const a = await getJson(`${trim(base)}/candles${query({ tf: o.tf, from: o.from, to: o.to })}`, fetchFn);
482
+ return (Array.isArray(a) ? a : []).map(toCandle).filter((c) => c !== null);
483
+ }
484
+ /** H3: `GET <base>/stats`. */
485
+ export async function readStats(base, fetchFn) {
486
+ const url = `${trim(base)}/stats`;
487
+ const s = toStats(await getJson(url, fetchFn));
488
+ if (!s)
489
+ throw new Error(`GET ${url}: not a stats record`);
490
+ return s;
491
+ }
492
+ /** H4: `GET <swap base>/stats` (`ids`: those enabled tokens only, in that order) → every enabled token's stats. */
493
+ export async function readAllStats(swapBase, ids, fetchFn) {
494
+ const a = await getJson(`${trim(swapBase)}/stats${ids ? `?ids=${list(ids)}` : ""}`, fetchFn);
495
+ return (Array.isArray(a) ? a : []).map(toStats).filter((s) => s !== null);
496
+ }
497
+ /** H6: `GET <token base>/color/<token>/holders?limit=&skip=` → the count and the rows, largest first. */
498
+ export async function readHolders(base, tokenId, o = {}, fetchFn) {
499
+ const a = (await getJson(`${trim(base)}/color/${encodeURIComponent(tokenId)}/holders${query({ limit: o.limit, skip: o.skip })}`, fetchFn));
500
+ return {
501
+ count: int(a?.count),
502
+ rows: (Array.isArray(a?.rows) ? a.rows : []).filter((r) => r && typeof r.address === "string").map((r) => ({ address: String(r.address), balance: wireBig(r.balance) })),
503
+ };
504
+ }
505
+ /** H5: `GET <swap base>/positions?lp=k1,k2` → every enabled token's rows for the keys, each with its token. */
506
+ export async function readAllPositions(swapBase, lpPubKeys, fetchFn) {
507
+ if (lpPubKeys.length === 0)
508
+ return [];
509
+ const a = await getJson(`${trim(swapBase)}/positions?lp=${list(lpPubKeys)}`, fetchFn);
510
+ return (Array.isArray(a) ? a : [])
511
+ .filter((r) => r && typeof r.tokenId === "string" && typeof r.lpPubKey === "string")
512
+ .map((r) => ({
513
+ tokenId: String(r.tokenId),
514
+ lpPubKey: String(r.lpPubKey),
515
+ status: r.status === "live" || r.status === "rescinded" ? r.status : "closed",
516
+ outpoint: typeof r.outpoint === "string" ? r.outpoint : null,
517
+ current: isWirePool(r.current) ? toPoolState(r.current) : null,
518
+ sats: wireBig(r.sats),
519
+ tokens: wireBig(r.tokens),
520
+ swaps: Number(r.swaps ?? 0),
521
+ }));
522
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Prices (0.34.0, D6, David Case 2026-11-04: one source for prices): a token's price comes from
3
- * its pool list — the pools route the host answers (`GET <origin>/amm/pools`, held as an event
4
- * stream on every page) — Σsats / Σtokens over the pools listed there (the host lists only pools
5
- * whose validator is beating). No price is derived from beats (the 0.9.0 beat prices are gone);
2
+ * Prices (0.34.0, D6, David Case 2026-11-04: one source for prices; 0.39.0, D3, D7): a token's
3
+ * listed price comes from the host-wide stats (`GET <origin>/amm/stats`, read once per load and
4
+ * every 30 s while the tab is visible; no host-wide stream), the Swap page's quote from the pools
5
+ * its token's pools route lists. No price is derived from beats (the 0.9.0 beat prices are gone);
6
6
  * the depth is the sats a buy needs to move the price up by 2%, x·(√1.02 − 1), over the summed
7
7
  * sats.
8
8
  */
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Prices (0.34.0, D6, David Case 2026-11-04: one source for prices): a token's price comes from
3
- * its pool list — the pools route the host answers (`GET <origin>/amm/pools`, held as an event
4
- * stream on every page) — Σsats / Σtokens over the pools listed there (the host lists only pools
5
- * whose validator is beating). No price is derived from beats (the 0.9.0 beat prices are gone);
2
+ * Prices (0.34.0, D6, David Case 2026-11-04: one source for prices; 0.39.0, D3, D7): a token's
3
+ * listed price comes from the host-wide stats (`GET <origin>/amm/stats`, read once per load and
4
+ * every 30 s while the tab is visible; no host-wide stream), the Swap page's quote from the pools
5
+ * its token's pools route lists. No price is derived from beats (the 0.9.0 beat prices are gone);
6
6
  * the depth is the sats a buy needs to move the price up by 2%, x·(√1.02 − 1), over the summed
7
7
  * sats.
8
8
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcasegr/amm-client",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "description": "The Open Exchange client (skein-amm): reads, swap, pool deploy / close / rebalance, move back and positions over a BRC-100 wallet, bound to one exchange by createClient. No React, no styles, no wallet provider.",
6
6
  "repository": {
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@1sat/actions": "0.1.2",
33
- "@dcasegr/amm-engine": "0.38.1",
32
+ "@1sat/actions": "0.1.3",
33
+ "@dcasegr/amm-engine": "0.39.0",
34
34
  "cbor2": "^2.3.0"
35
35
  },
36
36
  "peerDependencies": {