@logswap/sdk 0.5.52 → 0.5.54

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/liquidity.js CHANGED
@@ -25,7 +25,8 @@ import { logswapLensAbi, logswapRouterAbi } from "./generated.js";
25
25
  import { NO_CAP } from "./ids.js";
26
26
  import { describeId, getPositionClass } from "./positions.js";
27
27
  import { execute, OPEN_DELTA, settleInKind, settleOnly, swapExactInAction, swapExactOutAction, updateAction, } from "./actions.js";
28
- import { requireWallet, resolveDeadline, resolveRecipient, sendRouterWrite, sendRouterWriteWithPermits, } from "./write.js";
28
+ import { requireWallet, resolveDeadline, resolveRecipient, sendRouterWrite, sendRouterWriteWithPermits, withSlippage, } from "./write.js";
29
+ import { quoteExactIn, quoteExactOut } from "./swap.js";
29
30
  /**
30
31
  * Mint at `targetFloor`, blending across the two adjacent rungs when it is off-grid.
31
32
  *
@@ -37,7 +38,6 @@ import { requireWallet, resolveDeadline, resolveRecipient, sendRouterWrite, send
37
38
  * costs zero quote too — that is what makes single-asset entry possible in both directions.
38
39
  */
39
40
  export async function mint(c, a) {
40
- const MAX = (1n << 256n) - 1n;
41
41
  return sendRouterWriteWithPermits(c, {
42
42
  abi: logswapRouterAbi,
43
43
  functionName: "mintBlended",
@@ -46,8 +46,8 @@ export async function mint(c, a) {
46
46
  a.targetFloor,
47
47
  a.cap ?? NO_CAP,
48
48
  a.L,
49
- a.maxBaseIn ?? MAX,
50
- a.maxQuoteIn ?? MAX,
49
+ a.maxBaseIn,
50
+ a.maxQuoteIn,
51
51
  resolveRecipient(c, a),
52
52
  resolveDeadline(a),
53
53
  ],
@@ -62,7 +62,6 @@ export async function mint(c, a) {
62
62
  * uncapped.
63
63
  */
64
64
  export async function createMarket(c, a) {
65
- const MAX = (1n << 256n) - 1n;
66
65
  return sendRouterWriteWithPermits(c, {
67
66
  abi: logswapRouterAbi,
68
67
  functionName: "createMarket",
@@ -71,8 +70,8 @@ export async function createMarket(c, a) {
71
70
  a.x0,
72
71
  a.targetFloor,
73
72
  a.L,
74
- a.maxBaseIn ?? MAX,
75
- a.maxQuoteIn ?? MAX,
73
+ a.maxBaseIn,
74
+ a.maxQuoteIn,
76
75
  resolveRecipient(c, a),
77
76
  resolveDeadline(a),
78
77
  ],
@@ -94,11 +93,11 @@ export async function zapIn(c, a) {
94
93
  a.key,
95
94
  a.fundWithBase,
96
95
  a.amountIn,
97
- a.minSwapOut ?? 0n,
96
+ a.minSwapOut,
98
97
  a.targetFloor,
99
98
  a.cap ?? NO_CAP,
100
99
  a.L,
101
- a.maxIn ?? (1n << 256n) - 1n,
100
+ a.maxIn,
102
101
  resolveRecipient(c, a),
103
102
  resolveDeadline(a),
104
103
  ],
@@ -106,7 +105,6 @@ export async function zapIn(c, a) {
106
105
  }
107
106
  /** The primitive, through the router's named entry: resize and reposition in one call, in kind. */
108
107
  export async function update(c, a) {
109
- const MAX = (1n << 256n) - 1n;
110
108
  return sendRouterWriteWithPermits(c, {
111
109
  abi: logswapRouterAbi,
112
110
  functionName: "update",
@@ -117,8 +115,8 @@ export async function update(c, a) {
117
115
  a.toFloor,
118
116
  a.toCap ?? NO_CAP,
119
117
  a.newL,
120
- a.maxBaseIn ?? MAX,
121
- a.maxQuoteIn ?? MAX,
118
+ a.maxBaseIn,
119
+ a.maxQuoteIn,
122
120
  resolveRecipient(c, a),
123
121
  resolveDeadline(a),
124
122
  ],
@@ -129,11 +127,9 @@ export async function update(c, a) {
129
127
  *
130
128
  * **Bound it.** The router used to settle this entry with no limit, which meant a reposition that
131
129
  * priced differently by inclusion time could pull the whole allowance (security-review2 M-01).
132
- * The defaults below are unbounded for callers who genuinely want that; pass `maxBaseIn` /
133
- * `maxQuoteIn` from a preview for anything user-facing.
130
+ * The bounds are required; {@link UNBOUNDED} is an explicit, visible opt-out.
134
131
  */
135
132
  export async function move(c, a) {
136
- const MAX = (1n << 256n) - 1n;
137
133
  return sendRouterWrite(c, {
138
134
  abi: logswapRouterAbi,
139
135
  functionName: "move",
@@ -143,8 +139,8 @@ export async function move(c, a) {
143
139
  a.shares,
144
140
  a.toFloor,
145
141
  a.toCap ?? NO_CAP,
146
- a.maxBaseIn ?? MAX,
147
- a.maxQuoteIn ?? MAX,
142
+ a.maxBaseIn,
143
+ a.maxQuoteIn,
148
144
  resolveRecipient(c, a),
149
145
  resolveDeadline(a),
150
146
  ],
@@ -161,11 +157,26 @@ export async function previewEdit(c, a) {
161
157
  }));
162
158
  return { dBase: r[0], dQuote: r[1], fees: r[2] };
163
159
  }
160
+ /**
161
+ * Bounds for a pair of signed flows (+ = the caller pays, − = the caller receives), at `bps` of
162
+ * tolerance: a payment is capped from above, a receipt floored from below, and a token that does
163
+ * not move in that direction is bound to zero.
164
+ */
165
+ export function boundsFromFlows(dBase, dQuote, bps) {
166
+ const inb = (d) => (d > 0n ? withSlippage(d, bps, "input") : 0n);
167
+ const outb = (d) => (d < 0n ? withSlippage(-d, bps, "output") : 0n);
168
+ return { maxBaseIn: inb(dBase), maxQuoteIn: inb(dQuote), minBaseOut: outb(dBase), minQuoteOut: outb(dQuote) };
169
+ }
164
170
  /**
165
171
  * The general edit as a batch: `[update]`, plus — when `settleIn` is one token — the one swap that
166
172
  * nets the other. The other side's flow is read off the preview: released → sold with an exact-in
167
173
  * of `OPEN_DELTA`; owed → bought with an exact-out of `OPEN_DELTA`. The settlement then binds the
168
174
  * other token to zero both ways, so a stale preview fails loudly rather than moving it.
175
+ *
176
+ * **Bounded by default.** The preview gives the edit's exact flows at today's state; `slippageBps`
177
+ * turns them into the settlement's bounds. With `settleIn` one token, the netting swap is quoted
178
+ * on today's curve, before the update changes the active liquidity — close for a small edit, and
179
+ * for a large one a bound that reverts rather than one that lets a sandwich through.
169
180
  */
170
181
  export async function edit(c, a) {
171
182
  const { key } = a;
@@ -174,27 +185,37 @@ export async function edit(c, a) {
174
185
  updateAction({ key, fromId: a.fromId, shares: a.shares, toFloor: a.toFloor, toCap: a.toCap, newL: a.newL }),
175
186
  ];
176
187
  const settleIn = a.settleIn ?? "kind";
188
+ const p = await previewEdit(c, { key, fromId: a.fromId, shares: a.shares, toFloor: a.toFloor, toCap: a.toCap, newL: a.newL });
177
189
  let settle;
178
190
  if (settleIn === "kind") {
179
- settle = settleInKind(key, recipient, a);
191
+ settle = settleInKind(key, recipient, { ...boundsFromFlows(p.dBase, p.dQuote, a.slippageBps), ...defined(a) });
180
192
  }
181
193
  else {
182
- const p = await previewEdit(c, { key, fromId: a.fromId, shares: a.shares, toFloor: a.toFloor, toCap: a.toCap, newL: a.newL });
183
194
  // the swept fees ride INSIDE dQuote (the manager burns by owner and nets them; decisions 018),
184
195
  // so quote's open delta after the update is dQuote itself — `fees` is reported for display only
185
196
  const otherFlow = settleIn === "base" ? p.dQuote : p.dBase; // + = owed, − = released
186
197
  const otherIsBase = settleIn === "quote";
187
- if (otherFlow < 0n)
198
+ let own = settleIn === "base" ? p.dBase : p.dQuote;
199
+ if (otherFlow < 0n) {
188
200
  actions.push(swapExactInAction({ key, baseIn: otherIsBase, amountIn: OPEN_DELTA }));
189
- else if (otherFlow > 0n)
201
+ own -= await quoteExactIn(c, key, otherIsBase, -otherFlow);
202
+ }
203
+ else if (otherFlow > 0n) {
190
204
  actions.push(swapExactOutAction({ key, baseOut: otherIsBase, amountOut: OPEN_DELTA }));
205
+ own += await quoteExactOut(c, key, otherIsBase, otherFlow);
206
+ }
207
+ const b = settleIn === "base" ? boundsFromFlows(own, 0n, a.slippageBps) : boundsFromFlows(0n, own, a.slippageBps);
191
208
  settle = settleOnly(key, recipient, settleIn, {
192
- maxIn: settleIn === "base" ? a.maxBaseIn : a.maxQuoteIn,
193
- minOut: settleIn === "base" ? a.minBaseOut : a.minQuoteOut,
209
+ maxIn: settleIn === "base" ? (a.maxBaseIn ?? b.maxBaseIn) : (a.maxQuoteIn ?? b.maxQuoteIn),
210
+ minOut: settleIn === "base" ? (a.minBaseOut ?? b.minBaseOut) : (a.minQuoteOut ?? b.minQuoteOut),
194
211
  });
195
212
  }
196
213
  return execute(c, { actions, settle, deadline: a.deadline, recipient });
197
214
  }
215
+ /** The explicit bounds a caller passed, without the undefined ones (so they do not erase derived ones). */
216
+ function defined(b) {
217
+ return Object.fromEntries(["maxBaseIn", "maxQuoteIn", "minBaseOut", "minQuoteOut"].filter((k) => b[k] !== undefined).map((k) => [k, b[k]]));
218
+ }
198
219
  /** The class behind `shares` of `id`: its floor, cap, and the exposure those shares carry. */
199
220
  async function classOf(c, key, id, shares) {
200
221
  const [d, cls] = await Promise.all([describeId(c, id, key.tickSpacing), getPositionClass(c, id)]);
@@ -297,8 +318,8 @@ export async function burn(c, a) {
297
318
  a.key,
298
319
  a.ids,
299
320
  a.shares,
300
- a.minBaseOut ?? 0n,
301
- a.minQuoteOut ?? 0n,
321
+ a.minBaseOut,
322
+ a.minQuoteOut,
302
323
  resolveRecipient(c, a),
303
324
  resolveDeadline(a),
304
325
  ],
package/dist/pools.d.ts CHANGED
@@ -73,6 +73,8 @@ export interface DiscoveredMarket {
73
73
  x0: bigint;
74
74
  blockNumber: bigint;
75
75
  transactionHash: Hex;
76
+ /** The manager that emitted the `Initialize` — set when the row came from the indexer. */
77
+ manager?: Address;
76
78
  }
77
79
  /**
78
80
  * Enumerate markets from `Initialize` logs.
package/dist/pools.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { formatUnits } from "viem";
15
15
  import { logswapLensAbi, cPoolManagerAbi } from "./generated.js";
16
+ import { signerFor } from "./write.js";
16
17
  import { readCachedToken, registerCacheClear, writeCachedToken } from "./tokencache.js";
17
18
  import { poolId } from "./keys.js";
18
19
  /** Read a market's full state by key. */
@@ -137,19 +138,10 @@ export async function isValidFloor(c, key, floor) {
137
138
  * magnitude while the contract behaves perfectly — which reads as "the app is broken".
138
139
  */
139
140
  export async function marketDecimals(c, key) {
140
- const abi = [
141
- { type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] },
142
- ];
143
- const read = async (token) => {
144
- try {
145
- return Number(await c.public.readContract({ address: token, abi, functionName: "decimals" }));
146
- }
147
- catch {
148
- return 18; // same fallback the manager uses when the token does not implement it
149
- }
150
- };
151
- const [base, quote] = await Promise.all([read(key.base), read(key.quote)]);
152
- return { base, quote };
141
+ // It used to fall back to 18 on ANY failure, a dropped request included — a 6-decimal quote read
142
+ // as 18 is every amount 10^12 off. `marketTokens` retries and then refuses; so does this.
143
+ const { base, quote } = await marketTokens(c, key);
144
+ return { base: base.decimals, quote: quote.decimals };
153
145
  }
154
146
  /**
155
147
  * Both sides of a market, for labelling and scaling.
@@ -318,7 +310,7 @@ export async function initializeMarket(c, a) {
318
310
  abi: cPoolManagerAbi,
319
311
  functionName: "initialize",
320
312
  args: [a.key, a.x0],
321
- account: a.account,
313
+ account: signerFor(c, a.account),
322
314
  });
323
315
  return c.wallet.writeContract(request);
324
316
  }
package/dist/write.d.ts CHANGED
@@ -38,6 +38,14 @@ export declare function resolveDeadline(o: WriteOptions | undefined, now?: numbe
38
38
  * until someone uses a key the node does not have — a bot, a script, CI, or a real user's wallet.
39
39
  * Passing the object lets viem pick the correct path for both kinds.
40
40
  */
41
+ /**
42
+ * The wallet's account OBJECT for a write the caller named by address — see {@link requireWallet}
43
+ * for why the object and never the address. The F helpers take `account: Address` in their
44
+ * arguments; passed straight to viem it became a JSON-RPC account and the NODE was asked to sign,
45
+ * which works only on a dev node holding the key (every agent on the devnet did exactly that).
46
+ * A named account that is not the wallet's is refused: nothing else could sign for it.
47
+ */
48
+ export declare function signerFor(c: LogswapClient, address: Address): NonNullable<LogswapClient["wallet"]>["account"];
41
49
  export declare function requireWallet(c: LogswapClient): {
42
50
  account: NonNullable<LogswapClient["wallet"]>["account"];
43
51
  address: Address;
package/dist/write.js CHANGED
@@ -32,6 +32,20 @@ export function resolveDeadline(o, now = Date.now()) {
32
32
  * until someone uses a key the node does not have — a bot, a script, CI, or a real user's wallet.
33
33
  * Passing the object lets viem pick the correct path for both kinds.
34
34
  */
35
+ /**
36
+ * The wallet's account OBJECT for a write the caller named by address — see {@link requireWallet}
37
+ * for why the object and never the address. The F helpers take `account: Address` in their
38
+ * arguments; passed straight to viem it became a JSON-RPC account and the NODE was asked to sign,
39
+ * which works only on a dev node holding the key (every agent on the devnet did exactly that).
40
+ * A named account that is not the wallet's is refused: nothing else could sign for it.
41
+ */
42
+ export function signerFor(c, address) {
43
+ const { account } = requireWallet(c);
44
+ if (account.address.toLowerCase() !== address.toLowerCase()) {
45
+ throw new Error(`logswap: the write names ${address} but the wallet signs as ${account.address}`);
46
+ }
47
+ return account;
48
+ }
35
49
  export function requireWallet(c) {
36
50
  const account = c.wallet?.account;
37
51
  if (!c.wallet || !account) {
@@ -53,7 +67,8 @@ export function withSlippage(quoted, bps, side) {
53
67
  if (bps < 0 || bps > 10_000)
54
68
  throw new Error(`logswap: slippage bps out of range: ${bps}`);
55
69
  const b = BigInt(Math.round(bps));
56
- return side === "output" ? (quoted * (10000n - b)) / 10000n : (quoted * (10000n + b)) / 10000n;
70
+ // an input bound rounds UP: flooring it would put a wei-exact quote at bps = 0 one wei short
71
+ return side === "output" ? (quoted * (10000n - b)) / 10000n : (quoted * (10000n + b) + 9999n) / 10000n;
57
72
  }
58
73
  /**
59
74
  * Send a router write with a gas buffer over an explicit estimate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logswap/sdk",
3
- "version": "0.5.52",
3
+ "version": "0.5.54",
4
4
  "description": "TypeScript SDK for Logswap, the liquidity primitive introducing the logarithmic invariant — typed wrappers over the CPoolManager / FPoolManager / Router / Lens surface.",
5
5
  "license": "GPL-2.0-or-later",
6
6
  "type": "module",
package/src/fpool.ts CHANGED
@@ -48,6 +48,7 @@ export { fPoolManagerAbi, padPolicyAbi, polPolicyAbi, privatePolicyAbi, publicPo
48
48
  */
49
49
  export const fPolicyBaseAbi = polPolicyAbi;
50
50
  import type { LogswapClient } from "./client.js";
51
+ import { signerFor } from "./write.js";
51
52
 
52
53
  const WAD = 10n ** 18n;
53
54
  const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
@@ -359,7 +360,12 @@ export interface FPoolSwapArgs {
359
360
  /** Leg index into `bases`. */
360
361
  j: number;
361
362
  amountIn: bigint;
362
- minOut?: bigint;
363
+ /**
364
+ * The least the swap must return. Required: an F swap is exact-in, and without a floor on the
365
+ * output a front-run takes the difference. {@link fPoolQuoteSwap} is exact; bound it with
366
+ * {@link withSlippage} (`"output"`).
367
+ */
368
+ minOut: bigint;
363
369
  /** Who receives the out leg. Defaults to the account. */
364
370
  to?: Address;
365
371
  /** Absolute unix seconds. Defaulted to a bounded window, as every router write is. */
@@ -379,7 +385,7 @@ async function fSwap(c: LogswapClient, a: FPoolSwapArgs, kind: FPoolQuoteKind, k
379
385
  return writeRouter(
380
386
  c,
381
387
  "swapExactIn",
382
- [a.poolId, kind, BigInt(a.j), BigInt(k), a.amountIn, a.minOut ?? 0n, a.to ?? a.account, a.deadline ?? defaultDeadline()],
388
+ [a.poolId, kind, BigInt(a.j), BigInt(k), a.amountIn, a.minOut, a.to ?? a.account, a.deadline ?? defaultDeadline()],
383
389
  a.account,
384
390
  [pull],
385
391
  );
@@ -407,10 +413,11 @@ export async function fPoolSwapBaseForBase(c: LogswapClient, a: FPoolSwapArgs &
407
413
  /** Deposit dL/L of every reserve, receive shares ∝ dL. θ is untouched by construction. */
408
414
  export async function fPoolMint(
409
415
  c: LogswapClient,
410
- a: { poolId: Hex; dL: bigint; maxQuoteIn?: bigint; account: Address; to?: Address },
416
+ /** `maxQuoteIn` is required: the quote leg is Q·dL/L at landing, and Q moves with every swap. */
417
+ a: { poolId: Hex; dL: bigint; maxQuoteIn: bigint; account: Address; to?: Address },
411
418
  ) {
412
419
  // shares go to `to` (the account by default); on a gated pool `to` must be allowed
413
- return writeFPool(c, a.poolId, "mint", [a.to ?? a.account, a.dL, a.maxQuoteIn ?? 2n ** 256n - 1n], a.account);
420
+ return writeFPool(c, a.poolId, "mint", [a.to ?? a.account, a.dL, a.maxQuoteIn], a.account);
414
421
  }
415
422
 
416
423
  /**
@@ -420,9 +427,27 @@ export async function fPoolMint(
420
427
  */
421
428
  export async function fPoolBurn(
422
429
  c: LogswapClient,
423
- a: { poolId: Hex; shares: bigint; minQuoteOut?: bigint; account: Address; owner?: Address },
430
+ /** `minQuoteOut` is required: the quote slice is Q·shares/S at landing. See {@link fPoolPreviewBurn}. */
431
+ a: { poolId: Hex; shares: bigint; minQuoteOut: bigint; account: Address; owner?: Address },
424
432
  ) {
425
- return writeFPool(c, a.poolId, "burn", [a.owner ?? a.account, a.shares, a.minQuoteOut ?? 0n], a.account);
433
+ return writeFPool(c, a.poolId, "burn", [a.owner ?? a.account, a.shares, a.minQuoteOut], a.account);
434
+ }
435
+
436
+ /**
437
+ * What burning `shares` returns at today's state — the manager's own arithmetic: every reserve and
438
+ * the quote pro rata, rounded down (FPoolManager.burn). Exact until the next swap or mint.
439
+ */
440
+ export function fPoolPreviewBurn(st: Pick<FPoolState, "Q" | "reserves" | "totalSupply">, shares: bigint): { quoteOut: bigint; basesOut: bigint[] } {
441
+ if (st.totalSupply === 0n) return { quoteOut: 0n, basesOut: st.reserves.map(() => 0n) };
442
+ return { quoteOut: (st.Q * shares) / st.totalSupply, basesOut: st.reserves.map((r) => (r * shares) / st.totalSupply) };
443
+ }
444
+
445
+ /**
446
+ * The quote {@link fPoolMint} pulls for `dL` at today's state: Q·dL/L, rounded up (the pool never
447
+ * under-collects), before any mint fee — so bound it with {@link withSlippage} (`"input"`).
448
+ */
449
+ export function fPoolPreviewMintQuote(st: Pick<FPoolState, "Q" | "L">, dL: bigint): bigint {
450
+ return st.L === 0n ? 0n : (st.Q * dL + st.L - 1n) / st.L;
426
451
  }
427
452
 
428
453
  // ─── zaps (on the router — one router, one Permit2 spender) ───────────────────
@@ -475,17 +500,37 @@ export async function fPoolZapIn(
475
500
  */
476
501
  export async function fPoolZapOut(
477
502
  c: LogswapClient,
478
- a: { poolId: Hex; shares: bigint; tokenOut: Address; minOut?: bigint; to?: Address; account: Address; deadline?: bigint },
503
+ /** `minOut` is required: the sale routes every leg through the curve. See {@link fPoolPreviewZapOut}. */
504
+ a: { poolId: Hex; shares: bigint; tokenOut: Address; minOut: bigint; to?: Address; account: Address; deadline?: bigint },
479
505
  ) {
480
506
  const to = a.to ?? a.account;
481
507
  return writeRouter(
482
508
  c,
483
509
  "zapOut",
484
- [a.poolId, a.shares, a.tokenOut, a.minOut ?? 0n, to, a.deadline ?? defaultDeadline()],
510
+ [a.poolId, a.shares, a.tokenOut, a.minOut, to, a.deadline ?? defaultDeadline()],
485
511
  a.account,
486
512
  );
487
513
  }
488
514
 
515
+ /**
516
+ * What {@link fPoolZapOut} returns at today's state: the router's own `zapOut`, simulated as the
517
+ * holder with no bound. Exact, but it needs what the real call needs — the shares, and the router
518
+ * as the holder's ERC-6909 operator — so it throws where the zap-out itself would revert.
519
+ */
520
+ export async function fPoolPreviewZapOut(
521
+ c: LogswapClient,
522
+ a: { poolId: Hex; shares: bigint; tokenOut: Address; account: Address },
523
+ ): Promise<bigint> {
524
+ const { result } = await c.public.simulateContract({
525
+ address: c.addresses.router,
526
+ abi: logswapRouterAbi,
527
+ functionName: "zapOut",
528
+ args: [a.poolId, a.shares, a.tokenOut, 0n, a.account, defaultDeadline()],
529
+ account: a.account,
530
+ } as never);
531
+ return result as bigint;
532
+ }
533
+
489
534
  /**
490
535
  * Roll a retired generation's bundle into the pool's live one (decisions 025, 032), on the
491
536
  * router under one lock: redeem `shares` of generation `gen` in place — the legs, the quote and
@@ -600,7 +645,7 @@ async function writeFPool(c: LogswapClient, poolId: Hex, functionName: string, a
600
645
  // one place all seven helpers share, is what keeps a caller from ever omitting it. The `as
601
646
  // never` casts blind tsc to arg counts, so this file's encode test is the real guard.
602
647
  args: [poolId, ...args],
603
- account,
648
+ account: signerFor(c, account),
604
649
  } as never);
605
650
  return wallet.writeContract(request as never);
606
651
  }
@@ -613,7 +658,7 @@ async function writeFPool0(c: LogswapClient, functionName: string, args: unknown
613
658
  abi: fPoolManagerAbi,
614
659
  functionName,
615
660
  args,
616
- account,
661
+ account: signerFor(c, account),
617
662
  } as never);
618
663
  return wallet.writeContract(request as never);
619
664
  }
@@ -644,7 +689,7 @@ async function writeRouter(
644
689
  abi: logswapRouterAbi,
645
690
  functionName: fn,
646
691
  args: sendArgs,
647
- account,
692
+ account: signerFor(c, account),
648
693
  } as never);
649
694
  return wallet.writeContract(request as never);
650
695
  }
@@ -705,7 +750,7 @@ export async function approveRouterForFPool(c: LogswapClient, account: Address)
705
750
  abi: fPoolManagerAbi,
706
751
  functionName: "setOperator",
707
752
  args: [c.addresses.router, true],
708
- account,
753
+ account: signerFor(c, account),
709
754
  } as never);
710
755
  return wallet.writeContract(request as never);
711
756
  }
@@ -1225,7 +1270,7 @@ async function writePolicy(c: LogswapClient, abi: readonly unknown[], a: { poolI
1225
1270
  abi,
1226
1271
  functionName,
1227
1272
  args: [a.poolId, ...args],
1228
- account: a.account,
1273
+ account: signerFor(c, a.account),
1229
1274
  } as never);
1230
1275
  return wallet.writeContract(request as never);
1231
1276
  }
@@ -1317,7 +1362,7 @@ export async function fPoolSetSponsorName(c: LogswapClient, a: { who: Address; n
1317
1362
  abi: policyRegistryAbi,
1318
1363
  functionName: "setSponsorName",
1319
1364
  args: [a.who, textToBytes32(a.name)],
1320
- account: a.account,
1365
+ account: signerFor(c, a.account),
1321
1366
  } as never);
1322
1367
  return wallet.writeContract(request as never);
1323
1368
  }
@@ -1392,7 +1437,7 @@ export async function fPoolTransferShares(
1392
1437
  abi: fPoolManagerAbi,
1393
1438
  functionName: "transfer",
1394
1439
  args: [a.to, fPoolShareId(a.poolId, gen), a.shares],
1395
- account: a.account,
1440
+ account: signerFor(c, a.account),
1396
1441
  } as never);
1397
1442
  return wallet.writeContract(request as never);
1398
1443
  }
package/src/indexer.ts CHANGED
@@ -34,25 +34,70 @@ export function bytea(hex: Hex): string {
34
34
  return `\\x${hex.replace(/^0x/, "")}`;
35
35
  }
36
36
 
37
+ /** How long an indexer call may take before the caller falls back (to the chain, or an empty view). */
38
+ export const INDEXER_TIMEOUT_MS = 8_000;
39
+
37
40
  export async function gql<T>(
38
41
  url: string,
39
42
  query: string,
40
43
  variables?: Record<string, unknown>,
41
44
  signal?: AbortSignal,
42
45
  ): Promise<T> {
46
+ // A hung indexer must not hang its caller: every consumer has a fallback, and it only runs when
47
+ // this call ends. The caller's signal, if any, still wins.
43
48
  const res = await fetch(url, {
44
49
  method: "POST",
45
50
  headers: { "content-type": "application/json" },
46
51
  body: JSON.stringify({ query, variables }),
47
- signal,
52
+ signal: signal ?? AbortSignal.timeout(INDEXER_TIMEOUT_MS),
48
53
  });
49
54
  if (!res.ok) throw new Error(`indexer: HTTP ${res.status} from ${url}`);
55
+ // an Access login page or a proxy's error page answers 200 with HTML; say so, rather than
56
+ // "Unexpected token <"
57
+ const ct = res.headers.get("content-type") ?? "";
58
+ if (!ct.includes("json")) throw new Error(`indexer: ${url} answered ${ct || "no content-type"}, not JSON (a login page?)`);
50
59
  const body = (await res.json()) as { data?: T; errors?: IndexerError[] };
51
60
  if (body.errors?.length) throw new Error(`indexer: ${body.errors.map((e) => e.message).join("; ")}`);
52
61
  if (!body.data) throw new Error("indexer: empty response");
53
62
  return body.data;
54
63
  }
55
64
 
65
+ const PAGE = 1000;
66
+ const MAX_PAGES = 100;
67
+
68
+ /**
69
+ * Every row of a connection, page by page. A single `first: 1000` silently drops row 1001 onward —
70
+ * a pool's 1001st position class, a deployment's 1001st market — with nothing to say so. Pages by
71
+ * cursor in primary-key order, so rows inserted while paging cannot shift the window.
72
+ */
73
+ export async function gqlAll<N>(
74
+ url: string,
75
+ field: string,
76
+ selection: string,
77
+ opts: { vars?: Record<string, { type: string; value: unknown }>; condition?: string; signal?: AbortSignal } = {},
78
+ ): Promise<N[]> {
79
+ const vars = opts.vars ?? {};
80
+ const decl = Object.entries(vars).map(([k, v]) => `, $${k}: ${v.type}`).join("");
81
+ const cond = opts.condition ? `, condition: ${opts.condition}` : "";
82
+ const q = `query Page($after: Cursor${decl}) {
83
+ ${field}(first: ${PAGE}, after: $after, orderBy: PRIMARY_KEY_ASC${cond}) {
84
+ pageInfo { hasNextPage endCursor } nodes { ${selection} }
85
+ }
86
+ }`;
87
+ const values = Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.value]));
88
+ const out: N[] = [];
89
+ let after: string | null = null;
90
+ for (let i = 0; i < MAX_PAGES; i++) {
91
+ type Conn = { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: N[] };
92
+ const d: Record<string, Conn> = await gql<Record<string, Conn>>(url, q, { ...values, after }, opts.signal);
93
+ const page: Conn = d[field]!;
94
+ out.push(...page.nodes);
95
+ if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) return out;
96
+ after = page.pageInfo.endCursor;
97
+ }
98
+ throw new Error(`indexer: ${field} has more than ${PAGE * MAX_PAGES} rows`);
99
+ }
100
+
56
101
  /** A position class as the chain registered it: the floor/cap geometry, fixed for the id's life. */
57
102
  export interface RegisteredId {
58
103
  id: bigint;
@@ -63,16 +108,12 @@ export interface RegisteredId {
63
108
 
64
109
  /** Every position class ever registered in a pool. Historical — some may since have burnt to zero. */
65
110
  export async function registeredIds(url: string, poolId: Hex, signal?: AbortSignal): Promise<RegisteredId[]> {
66
- const q = `query Ids($pool: String!) {
67
- allIdRegistereds(condition: {poolId: $pool}, first: 1000) { nodes { id floor cap capped } }
68
- }`;
69
- const d = await gql<{ allIdRegistereds: { nodes: { id: string; floor: string; cap: string; capped: boolean }[] } }>(
70
- url,
71
- q,
72
- { pool: bytea(poolId) },
111
+ const nodes = await gqlAll<{ id: string; floor: string; cap: string; capped: boolean }>(url, "allIdRegistereds", "id floor cap capped", {
112
+ vars: { pool: { type: "String!", value: bytea(poolId) } },
113
+ condition: "{poolId: $pool}",
73
114
  signal,
74
- );
75
- return d.allIdRegistereds.nodes.map((n) => ({
115
+ });
116
+ return nodes.map((n) => ({
76
117
  id: BigInt(n.id),
77
118
  floor: BigInt(n.floor),
78
119
  cap: BigInt(n.cap),
@@ -142,15 +183,12 @@ export async function indexerHealthy(url: string, signal?: AbortSignal): Promise
142
183
  * everywhere else, so a wrong id fails loudly instead of addressing the wrong pool.
143
184
  */
144
185
  export async function discoverMarketsIndexed(url: string, signal?: AbortSignal): Promise<DiscoveredMarket[]> {
145
- const q = `{ allInitializes(first: 1000) { nodes {
146
- poolId base quote tickSpacing phi x0 blockNumber txHash
147
- } } }`;
148
186
  type Row = {
149
187
  poolId: Hex; base: Hex; quote: Hex; tickSpacing: string; phi: string;
150
- x0: string; blockNumber: string; txHash: Hex;
188
+ x0: string; blockNumber: string; txHash: Hex; contractAddress: Hex;
151
189
  };
152
- const d = await gql<{ allInitializes: { nodes: Row[] } }>(url, q, undefined, signal);
153
- return d.allInitializes.nodes.map((n) => {
190
+ const nodes = await gqlAll<Row>(url, "allInitializes", "poolId base quote tickSpacing phi x0 blockNumber txHash contractAddress", { signal });
191
+ return nodes.map((n) => {
154
192
  const key: PoolKey = {
155
193
  base: n.base as Address,
156
194
  quote: n.quote as Address,
@@ -163,6 +201,7 @@ export async function discoverMarketsIndexed(url: string, signal?: AbortSignal):
163
201
  x0: BigInt(n.x0),
164
202
  blockNumber: BigInt(n.blockNumber),
165
203
  transactionHash: n.txHash,
204
+ manager: n.contractAddress,
166
205
  };
167
206
  });
168
207
  }
@@ -181,31 +220,14 @@ export async function discoverMarketsBest(
181
220
  if (url) {
182
221
  try {
183
222
  const markets = await discoverMarketsIndexed(url, signal);
184
- // The indexer is a CACHE and the chain is the truth. After a redeploy the indexer keeps
185
- // serving the previous deployment's markets; calling the new manager with those keys makes
186
- // every read revert, refresh-proof (it presented as "phiEff reverted", forever). So verify
187
- // each candidate against the manager the client is actually pointed at, and keep only the
188
- // markets that exist there; if none survive, the chain scan is the answer.
189
- if (markets.length) {
190
- const alive = (
191
- await Promise.all(
192
- markets.map(async (m) => {
193
- try {
194
- await c.public.readContract({
195
- address: c.addresses.cPoolManager,
196
- abi: cPoolManagerAbi,
197
- functionName: "phiOf",
198
- args: [m.key],
199
- } as never);
200
- return m;
201
- } catch {
202
- return null;
203
- }
204
- }),
205
- )
206
- ).filter((m): m is DiscoveredMarket => m !== null);
207
- if (alive.length) return { markets: alive, source: "indexer" };
208
- }
223
+ // The indexer is a CACHE and the chain is the truth. After a redeploy it keeps serving the
224
+ // previous deployment's markets, and calling the new manager with those keys makes every
225
+ // read revert, refresh-proof. Each row names the manager that emitted it, so keep only the
226
+ // rows of the manager this client is pointed at — no read per row. None left means the
227
+ // indexer has not seen this deployment: the chain scan is the answer.
228
+ const mine = c.addresses.cPoolManager.toLowerCase();
229
+ const alive = markets.filter((m) => m.manager?.toLowerCase() === mine);
230
+ if (alive.length) return { markets: alive, source: "indexer" };
209
231
  } catch {
210
232
  /* fall through — a missing indexer must not cost you the market list */
211
233
  }