@logswap/sdk 0.5.61 → 0.5.63

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
@@ -123,30 +123,27 @@ export const EXPECTED_VERSION = 5n;
123
123
  // `isTaker`; a pad and a POL hold one base (`OneBase`).
124
124
  export const EXPECTED_F_VERSION = 19n;
125
125
  export async function assertVersion(c, expected = EXPECTED_VERSION) {
126
- const { cPoolManagerAbi } = await import("./generated.js");
127
- const got = await c.public.readContract({
128
- address: c.addresses.cPoolManager,
129
- abi: cPoolManagerAbi,
130
- functionName: "version",
131
- });
126
+ const { cPoolManagerAbi, fPoolManagerAbi } = await import("./generated.js");
127
+ // three independent reads: one round trip, not three in a row
128
+ const [got, gotF, wiring] = await Promise.all([
129
+ c.public.readContract({ address: c.addresses.cPoolManager, abi: cPoolManagerAbi, functionName: "version" }),
130
+ // the F manager carries its own tag; assert it the same way when deployed
131
+ c.addresses.fPoolManager
132
+ ? c.public.readContract({ address: c.addresses.fPoolManager, abi: fPoolManagerAbi, functionName: "version" })
133
+ : Promise.resolve(null),
134
+ assertWiring(c).then(() => null, (e) => e),
135
+ ]);
132
136
  if (got !== expected) {
133
137
  throw new Error(`logswap: manager at ${c.addresses.cPoolManager} reports version ${got}, but this SDK was built ` +
134
138
  `against version ${expected}. Upgrade the SDK or point at the matching deployment.`);
135
139
  }
136
- // the F manager carries its own tag; assert it the same way when deployed
137
- if (c.addresses.fPoolManager) {
138
- const { fPoolManagerAbi } = await import("./generated.js");
139
- const gotF = await c.public.readContract({
140
- address: c.addresses.fPoolManager,
141
- abi: fPoolManagerAbi,
142
- functionName: "version",
143
- });
144
- if (gotF < EXPECTED_F_VERSION) {
145
- throw new Error(`logswap: F manager at ${c.addresses.fPoolManager} reports version ${gotF}, but this SDK needs ` +
146
- `at least F version ${EXPECTED_F_VERSION}. Upgrade the deployment or point at a newer one.`);
147
- }
140
+ if (gotF !== null && gotF < EXPECTED_F_VERSION) {
141
+ throw new Error(`logswap: F manager at ${c.addresses.fPoolManager} reports version ${gotF}, but this SDK needs ` +
142
+ `at least F version ${EXPECTED_F_VERSION}. Upgrade the deployment or point at a newer one.`);
148
143
  }
149
- await assertWiring(c);
144
+ // the wiring's error is the less specific one, so a version mismatch is reported first
145
+ if (wiring)
146
+ throw wiring;
150
147
  }
151
148
  /**
152
149
  * The versions say the managers are the right code; this says the rest of the addresses belong
package/dist/fpool.d.ts CHANGED
@@ -1433,11 +1433,13 @@ export interface DiscoveredFPool {
1433
1433
  * Every F pool ever created, from `Initialize` logs alone — plus one `kind()` read per distinct
1434
1434
  * policy, cached. Possible only because the event carries the full key preimage (bases,
1435
1435
  * weights, phi, policy) — the poolId is a hash and could never be inverted; contracts PR #17
1436
- * exists for exactly this call.
1436
+ * exists for exactly this call. With `indexer`, the logs come from it (the node when it fails):
1437
+ * a scan from block 0 per call is what made the first load wait on the node.
1437
1438
  */
1438
1439
  export declare function discoverFPools(c: LogswapClient, opts?: {
1439
1440
  fromBlock?: bigint;
1440
1441
  toBlock?: bigint;
1442
+ indexer?: string;
1441
1443
  }): Promise<DiscoveredFPool[]>;
1442
1444
  /** A pool an authority controls, with what the policy page shows of it. */
1443
1445
  export interface FPoolControlled {
package/dist/fpool.js CHANGED
@@ -46,6 +46,7 @@ export { fPoolManagerAbi, padPolicyAbi, polPolicyAbi, privatePolicyAbi, publicPo
46
46
  */
47
47
  export const fPolicyBaseAbi = polPolicyAbi;
48
48
  import { fallBackUnlessRejected, signerFor } from "./write.js";
49
+ import { getEventLogs } from "./history.js";
49
50
  const WAD = 10n ** 18n;
50
51
  const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
51
52
  /** The optional questions a policy declared at birth, as bits of `Pool.asks`. */
@@ -1044,23 +1045,18 @@ export async function fPoolTransferShares(c, a) {
1044
1045
  * Every F pool ever created, from `Initialize` logs alone — plus one `kind()` read per distinct
1045
1046
  * policy, cached. Possible only because the event carries the full key preimage (bases,
1046
1047
  * weights, phi, policy) — the poolId is a hash and could never be inverted; contracts PR #17
1047
- * exists for exactly this call.
1048
+ * exists for exactly this call. With `indexer`, the logs come from it (the node when it fails):
1049
+ * a scan from block 0 per call is what made the first load wait on the node.
1048
1050
  */
1049
1051
  export async function discoverFPools(c, opts = {}) {
1050
1052
  const ev = fPoolManagerAbi.find((x) => x.type === "event" && x.name === "FInitialize");
1051
1053
  if (!ev)
1052
1054
  throw new Error("logswap: F Initialize event missing from the generated ABI");
1053
1055
  const seedEv = fPoolManagerAbi.find((x) => x.type === "event" && x.name === "Seeded");
1054
- const [logs, seeds] = await Promise.all([
1055
- c.public.getLogs({
1056
- address: c.addresses.fPoolManager,
1057
- event: ev,
1058
- fromBlock: opts.fromBlock ?? 0n,
1059
- toBlock: opts.toBlock ?? "latest",
1060
- }),
1061
- seedEv
1062
- ? c.public.getLogs({ address: c.addresses.fPoolManager, event: seedEv, fromBlock: opts.fromBlock ?? 0n, toBlock: opts.toBlock ?? "latest" })
1063
- : Promise.resolve([]),
1056
+ const range = { indexer: opts.indexer, address: c.addresses.fPoolManager, fromBlock: opts.fromBlock, toBlock: opts.toBlock };
1057
+ const [{ logs }, { logs: seeds }] = await Promise.all([
1058
+ getEventLogs(c, { ...range, event: ev }),
1059
+ seedEv ? getEventLogs(c, { ...range, event: seedEv }) : Promise.resolve({ logs: [] }),
1064
1060
  ]);
1065
1061
  const q0Of = new Map();
1066
1062
  for (const l of seeds) {
@@ -1080,7 +1076,7 @@ export async function discoverFPools(c, opts = {}) {
1080
1076
  kind,
1081
1077
  policy: a.policy,
1082
1078
  creator: a.creator,
1083
- block: l.blockNumber ?? 0n,
1079
+ block: l.blockNumber,
1084
1080
  seeded: q0 !== undefined,
1085
1081
  q0: q0 ?? 0n,
1086
1082
  shape: kind === "pad" ? "launch" : "basket", // the policy's word, not n = 1: a mono POL is a pool
package/dist/history.d.ts CHANGED
@@ -36,6 +36,13 @@ export interface EventLogsQuery {
36
36
  toBlock?: bigint;
37
37
  /** Unix seconds: only events at or after this time. On the chain path it becomes a block search. */
38
38
  since?: number;
39
+ /**
40
+ * Only the newest `latest` events (still returned oldest first) — a pool's tape, not its history.
41
+ * On the chain path the node is asked for the last {@link LATEST_SCAN_BLOCKS} blocks only.
42
+ */
43
+ latest?: number;
44
+ /** Only events from these transactions — e.g. the `RouterCall`s behind a page of routed swaps. */
45
+ transactionHashes?: Hex[];
39
46
  signal?: AbortSignal;
40
47
  }
41
48
  /**
@@ -50,3 +57,5 @@ export declare function tableFor(c: LogswapClient, address: Address, event: stri
50
57
  field: string;
51
58
  type: string;
52
59
  };
60
+ /** How far back the chain path looks for a `latest` tape: a tape is recent by definition. */
61
+ export declare const LATEST_SCAN_BLOCKS = 5000n;
package/dist/history.js CHANGED
@@ -66,8 +66,10 @@ function decode(type, v) {
66
66
  return getAddress(v);
67
67
  return v; // bool, bytes32 (hex string), string
68
68
  }
69
- const PAGE = 1000;
69
+ const PAGE = 1000; // postgraphile's cap
70
70
  const MAX_PAGES = 200;
71
+ /** Pages in flight at once, after the first. */
72
+ const PARALLEL_PAGES = 8;
71
73
  async function fromIndexer(c, q) {
72
74
  const { field, type } = tableFor(c, q.address, q.event.name);
73
75
  const inputs = q.event.inputs;
@@ -97,23 +99,39 @@ async function fromIndexer(c, q) {
97
99
  filter.blockNumber = range;
98
100
  if (q.since !== undefined)
99
101
  filter.blockTimestamp = { greaterThanOrEqualTo: new Date(q.since * 1000).toISOString() };
102
+ if (q.transactionHashes)
103
+ filter.txHash = { in: q.transactionHashes.map((h) => h.toLowerCase()) };
104
+ // a tape is one page from the head, newest first; history is every page from the start
105
+ const pageSize = q.latest ?? PAGE;
106
+ const order = q.latest !== undefined ? "[BLOCK_NUMBER_DESC, LOG_INDEX_DESC]" : "PRIMARY_KEY_ASC";
100
107
  const selection = [...inputs.map((i) => column(i.name)), "contractAddress", "blockNumber", "blockTimestamp", "txHash", "logIndex"].join(" ");
101
- const query = `query History($after: Cursor, $condition: ${type}Condition, $filter: ${type}Filter) {
102
- ${field}(first: ${PAGE}, after: $after, orderBy: PRIMARY_KEY_ASC, condition: $condition, filter: $filter) {
103
- pageInfo { hasNextPage endCursor } nodes { ${selection} }
108
+ // postgraphile refuses a null or an empty filter, so each argument appears only when it has content
109
+ const hasCondition = Object.keys(condition).length > 0;
110
+ const hasFilter = Object.keys(filter).length > 0;
111
+ const decl = `$offset: Int${hasCondition ? `, $condition: ${type}Condition` : ""}${hasFilter ? `, $filter: ${type}Filter` : ""}`;
112
+ const argsOf = `first: ${pageSize}, offset: $offset, orderBy: ${order}${hasCondition ? ", condition: $condition" : ""}${hasFilter ? ", filter: $filter" : ""}`;
113
+ const query = `query History(${decl}) {
114
+ ${field}(${argsOf}) {
115
+ totalCount nodes { ${selection} }
104
116
  }
105
117
  }`;
106
- const rows = [];
107
- let after = null;
108
- for (let i = 0;; i++) {
109
- if (i === MAX_PAGES)
118
+ const page = async (offset) => (await gql(q.indexer, query, { offset, ...(hasCondition ? { condition } : {}), ...(hasFilter ? { filter } : {}) }, q.signal))[field];
119
+ // The first page says how many rows there are; the rest are fetched in parallel by offset, not
120
+ // one cursor after another: a day of a busy pool is a few pages, and a round trip to a distant
121
+ // indexer costs more than the page. Ascending by key, rows that land meanwhile append at the end
122
+ // and shift no earlier offset.
123
+ const first = await page(0);
124
+ const rows = [...first.nodes];
125
+ if (q.latest === undefined && first.totalCount > first.nodes.length) {
126
+ const pages = Math.ceil(first.totalCount / PAGE);
127
+ if (pages > MAX_PAGES)
110
128
  throw new Error(`history: ${field} has more than ${PAGE * MAX_PAGES} rows`);
111
- const d = await gql(q.indexer, query, { after, condition: Object.keys(condition).length ? condition : null, filter: Object.keys(filter).length ? filter : null }, q.signal);
112
- const page = d[field];
113
- rows.push(...page.nodes);
114
- if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor)
115
- break;
116
- after = page.pageInfo.endCursor;
129
+ const offsets = Array.from({ length: pages - 1 }, (_, i) => (i + 1) * PAGE);
130
+ for (let i = 0; i < offsets.length; i += PARALLEL_PAGES) {
131
+ const got = await Promise.all(offsets.slice(i, i + PARALLEL_PAGES).map(page));
132
+ for (const g of got)
133
+ rows.push(...g.nodes);
134
+ }
117
135
  }
118
136
  // a table can hold a previous deployment's rows (an indexer not yet reset): keep this contract's
119
137
  const want = q.address.toLowerCase();
@@ -130,12 +148,22 @@ async function fromIndexer(c, q) {
130
148
  .sort((a, b) => (a.blockNumber === b.blockNumber ? a.logIndex - b.logIndex : a.blockNumber < b.blockNumber ? -1 : 1));
131
149
  }
132
150
  // ─── the chain path ──────────────────────────────────────────────────────────
151
+ /** How far back the chain path looks for a `latest` tape: a tape is recent by definition. */
152
+ export const LATEST_SCAN_BLOCKS = 5000n;
133
153
  async function fromChain(c, q) {
134
154
  let fromBlock = q.fromBlock ?? 0n;
135
155
  const toBlock = q.toBlock ?? (await c.public.getBlockNumber());
136
156
  if (q.since !== undefined)
137
157
  fromBlock = await blockAtOrAfter(c, BigInt(q.since), fromBlock, toBlock);
138
- const logs = await c.public.getLogs({ address: q.address, event: q.event, args: q.args, fromBlock, toBlock });
158
+ if (q.latest !== undefined && toBlock - fromBlock > LATEST_SCAN_BLOCKS)
159
+ fromBlock = toBlock - LATEST_SCAN_BLOCKS;
160
+ let logs = (await c.public.getLogs({ address: q.address, event: q.event, args: q.args, fromBlock, toBlock }));
161
+ if (q.transactionHashes) {
162
+ const want = new Set(q.transactionHashes.map((h) => h.toLowerCase()));
163
+ logs = logs.filter((l) => want.has(l.transactionHash.toLowerCase()));
164
+ }
165
+ if (q.latest !== undefined)
166
+ logs = logs.slice(-q.latest);
139
167
  return logs.map((l) => ({
140
168
  address: q.address,
141
169
  args: l.args,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logswap/sdk",
3
- "version": "0.5.61",
3
+ "version": "0.5.63",
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/client.ts CHANGED
@@ -168,34 +168,30 @@ export const EXPECTED_VERSION = 5n;
168
168
  export const EXPECTED_F_VERSION = 19n;
169
169
 
170
170
  export async function assertVersion(c: LogswapClient, expected = EXPECTED_VERSION): Promise<void> {
171
- const { cPoolManagerAbi } = await import("./generated.js");
172
- const got = await c.public.readContract({
173
- address: c.addresses.cPoolManager,
174
- abi: cPoolManagerAbi,
175
- functionName: "version",
176
- });
171
+ const { cPoolManagerAbi, fPoolManagerAbi } = await import("./generated.js");
172
+ // three independent reads: one round trip, not three in a row
173
+ const [got, gotF, wiring] = await Promise.all([
174
+ c.public.readContract({ address: c.addresses.cPoolManager, abi: cPoolManagerAbi, functionName: "version" }),
175
+ // the F manager carries its own tag; assert it the same way when deployed
176
+ c.addresses.fPoolManager
177
+ ? c.public.readContract({ address: c.addresses.fPoolManager, abi: fPoolManagerAbi, functionName: "version" })
178
+ : Promise.resolve(null),
179
+ assertWiring(c).then(() => null, (e: unknown) => e),
180
+ ]);
177
181
  if (got !== expected) {
178
182
  throw new Error(
179
183
  `logswap: manager at ${c.addresses.cPoolManager} reports version ${got}, but this SDK was built ` +
180
184
  `against version ${expected}. Upgrade the SDK or point at the matching deployment.`,
181
185
  );
182
186
  }
183
- // the F manager carries its own tag; assert it the same way when deployed
184
- if (c.addresses.fPoolManager) {
185
- const { fPoolManagerAbi } = await import("./generated.js");
186
- const gotF = await c.public.readContract({
187
- address: c.addresses.fPoolManager,
188
- abi: fPoolManagerAbi,
189
- functionName: "version",
190
- });
191
- if (gotF < EXPECTED_F_VERSION) {
192
- throw new Error(
193
- `logswap: F manager at ${c.addresses.fPoolManager} reports version ${gotF}, but this SDK needs ` +
194
- `at least F version ${EXPECTED_F_VERSION}. Upgrade the deployment or point at a newer one.`,
195
- );
196
- }
187
+ if (gotF !== null && gotF < EXPECTED_F_VERSION) {
188
+ throw new Error(
189
+ `logswap: F manager at ${c.addresses.fPoolManager} reports version ${gotF}, but this SDK needs ` +
190
+ `at least F version ${EXPECTED_F_VERSION}. Upgrade the deployment or point at a newer one.`,
191
+ );
197
192
  }
198
- await assertWiring(c);
193
+ // the wiring's error is the less specific one, so a version mismatch is reported first
194
+ if (wiring) throw wiring;
199
195
  }
200
196
 
201
197
  /**
package/src/fpool.ts CHANGED
@@ -49,6 +49,7 @@ export { fPoolManagerAbi, padPolicyAbi, polPolicyAbi, privatePolicyAbi, publicPo
49
49
  export const fPolicyBaseAbi = polPolicyAbi;
50
50
  import type { LogswapClient } from "./client.js";
51
51
  import { fallBackUnlessRejected, signerFor } from "./write.js";
52
+ import { getEventLogs } from "./history.js";
52
53
 
53
54
  const WAD = 10n ** 18n;
54
55
  const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
@@ -1474,27 +1475,22 @@ export interface DiscoveredFPool {
1474
1475
  * Every F pool ever created, from `Initialize` logs alone — plus one `kind()` read per distinct
1475
1476
  * policy, cached. Possible only because the event carries the full key preimage (bases,
1476
1477
  * weights, phi, policy) — the poolId is a hash and could never be inverted; contracts PR #17
1477
- * exists for exactly this call.
1478
+ * exists for exactly this call. With `indexer`, the logs come from it (the node when it fails):
1479
+ * a scan from block 0 per call is what made the first load wait on the node.
1478
1480
  */
1479
1481
  export async function discoverFPools(
1480
1482
  c: LogswapClient,
1481
- opts: { fromBlock?: bigint; toBlock?: bigint } = {},
1483
+ opts: { fromBlock?: bigint; toBlock?: bigint; indexer?: string } = {},
1482
1484
  ): Promise<DiscoveredFPool[]> {
1483
1485
  type Ev = Extract<(typeof fPoolManagerAbi)[number], { type: "event"; name: "FInitialize" }>;
1484
1486
  const ev = fPoolManagerAbi.find((x): x is Ev => x.type === "event" && x.name === "FInitialize");
1485
1487
  if (!ev) throw new Error("logswap: F Initialize event missing from the generated ABI");
1486
1488
  type SeedEv = Extract<(typeof fPoolManagerAbi)[number], { type: "event"; name: "Seeded" }>;
1487
1489
  const seedEv = fPoolManagerAbi.find((x): x is SeedEv => x.type === "event" && x.name === "Seeded");
1488
- const [logs, seeds] = await Promise.all([
1489
- c.public.getLogs({
1490
- address: c.addresses.fPoolManager!,
1491
- event: ev,
1492
- fromBlock: opts.fromBlock ?? 0n,
1493
- toBlock: opts.toBlock ?? "latest",
1494
- }),
1495
- seedEv
1496
- ? c.public.getLogs({ address: c.addresses.fPoolManager!, event: seedEv, fromBlock: opts.fromBlock ?? 0n, toBlock: opts.toBlock ?? "latest" })
1497
- : Promise.resolve([]),
1490
+ const range = { indexer: opts.indexer, address: c.addresses.fPoolManager!, fromBlock: opts.fromBlock, toBlock: opts.toBlock };
1491
+ const [{ logs }, { logs: seeds }] = await Promise.all([
1492
+ getEventLogs(c, { ...range, event: ev }),
1493
+ seedEv ? getEventLogs(c, { ...range, event: seedEv }) : Promise.resolve({ logs: [] }),
1498
1494
  ]);
1499
1495
  const q0Of = new Map<string, bigint>();
1500
1496
  for (const l of seeds) {
@@ -1518,7 +1514,7 @@ export async function discoverFPools(
1518
1514
  kind,
1519
1515
  policy: a.policy,
1520
1516
  creator: a.creator,
1521
- block: l.blockNumber ?? 0n,
1517
+ block: l.blockNumber,
1522
1518
  seeded: q0 !== undefined,
1523
1519
  q0: q0 ?? 0n,
1524
1520
  shape: kind === "pad" ? "launch": "basket", // the policy's word, not n = 1: a mono POL is a pool
package/src/history.ts CHANGED
@@ -40,6 +40,13 @@ export interface EventLogsQuery {
40
40
  toBlock?: bigint;
41
41
  /** Unix seconds: only events at or after this time. On the chain path it becomes a block search. */
42
42
  since?: number;
43
+ /**
44
+ * Only the newest `latest` events (still returned oldest first) — a pool's tape, not its history.
45
+ * On the chain path the node is asked for the last {@link LATEST_SCAN_BLOCKS} blocks only.
46
+ */
47
+ latest?: number;
48
+ /** Only events from these transactions — e.g. the `RouterCall`s behind a page of routed swaps. */
49
+ transactionHashes?: Hex[];
43
50
  signal?: AbortSignal;
44
51
  }
45
52
 
@@ -94,8 +101,10 @@ function decode(type: string, v: unknown): unknown {
94
101
  return v; // bool, bytes32 (hex string), string
95
102
  }
96
103
 
97
- const PAGE = 1000;
104
+ const PAGE = 1000; // postgraphile's cap
98
105
  const MAX_PAGES = 200;
106
+ /** Pages in flight at once, after the first. */
107
+ const PARALLEL_PAGES = 8;
99
108
 
100
109
  async function fromIndexer<A>(c: LogswapClient, q: EventLogsQuery): Promise<HistoryLog<A>[]> {
101
110
  const { field, type } = tableFor(c, q.address, q.event.name);
@@ -119,29 +128,40 @@ async function fromIndexer<A>(c: LogswapClient, q: EventLogsQuery): Promise<Hist
119
128
  if (q.toBlock !== undefined) range.lessThanOrEqualTo = q.toBlock.toString();
120
129
  if (Object.keys(range).length) filter.blockNumber = range;
121
130
  if (q.since !== undefined) filter.blockTimestamp = { greaterThanOrEqualTo: new Date(q.since * 1000).toISOString() };
131
+ if (q.transactionHashes) filter.txHash = { in: q.transactionHashes.map((h) => h.toLowerCase()) };
132
+ // a tape is one page from the head, newest first; history is every page from the start
133
+ const pageSize = q.latest ?? PAGE;
134
+ const order = q.latest !== undefined ? "[BLOCK_NUMBER_DESC, LOG_INDEX_DESC]" : "PRIMARY_KEY_ASC";
122
135
 
123
136
  const selection = [...inputs.map((i) => column(i.name!)), "contractAddress", "blockNumber", "blockTimestamp", "txHash", "logIndex"].join(" ");
124
- const query = `query History($after: Cursor, $condition: ${type}Condition, $filter: ${type}Filter) {
125
- ${field}(first: ${PAGE}, after: $after, orderBy: PRIMARY_KEY_ASC, condition: $condition, filter: $filter) {
126
- pageInfo { hasNextPage endCursor } nodes { ${selection} }
137
+ // postgraphile refuses a null or an empty filter, so each argument appears only when it has content
138
+ const hasCondition = Object.keys(condition).length > 0;
139
+ const hasFilter = Object.keys(filter).length > 0;
140
+ const decl = `$offset: Int${hasCondition ? `, $condition: ${type}Condition` : ""}${hasFilter ? `, $filter: ${type}Filter` : ""}`;
141
+ const argsOf = `first: ${pageSize}, offset: $offset, orderBy: ${order}${hasCondition ? ", condition: $condition" : ""}${hasFilter ? ", filter: $filter" : ""}`;
142
+ const query = `query History(${decl}) {
143
+ ${field}(${argsOf}) {
144
+ totalCount nodes { ${selection} }
127
145
  }
128
146
  }`;
129
147
  type Row = Record<string, unknown>;
130
- type Conn = { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: Row[] };
131
- const rows: Row[] = [];
132
- let after: string | null = null;
133
- for (let i = 0; ; i++) {
134
- if (i === MAX_PAGES) throw new Error(`history: ${field} has more than ${PAGE * MAX_PAGES} rows`);
135
- const d: Record<string, Conn> = await gql<Record<string, Conn>>(
136
- q.indexer!,
137
- query,
138
- { after, condition: Object.keys(condition).length ? condition : null, filter: Object.keys(filter).length ? filter : null },
139
- q.signal,
140
- );
141
- const page: Conn = d[field]!;
142
- rows.push(...page.nodes);
143
- if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) break;
144
- after = page.pageInfo.endCursor;
148
+ type Conn = { totalCount: number; nodes: Row[] };
149
+ const page = async (offset: number): Promise<Conn> =>
150
+ (await gql<Record<string, Conn>>(q.indexer!, query, { offset, ...(hasCondition ? { condition } : {}), ...(hasFilter ? { filter } : {}) }, q.signal))[field]!;
151
+ // The first page says how many rows there are; the rest are fetched in parallel by offset, not
152
+ // one cursor after another: a day of a busy pool is a few pages, and a round trip to a distant
153
+ // indexer costs more than the page. Ascending by key, rows that land meanwhile append at the end
154
+ // and shift no earlier offset.
155
+ const first = await page(0);
156
+ const rows: Row[] = [...first.nodes];
157
+ if (q.latest === undefined && first.totalCount > first.nodes.length) {
158
+ const pages = Math.ceil(first.totalCount / PAGE);
159
+ if (pages > MAX_PAGES) throw new Error(`history: ${field} has more than ${PAGE * MAX_PAGES} rows`);
160
+ const offsets = Array.from({ length: pages - 1 }, (_, i) => (i + 1) * PAGE);
161
+ for (let i = 0; i < offsets.length; i += PARALLEL_PAGES) {
162
+ const got = await Promise.all(offsets.slice(i, i + PARALLEL_PAGES).map(page));
163
+ for (const g of got) rows.push(...g.nodes);
164
+ }
145
165
  }
146
166
  // a table can hold a previous deployment's rows (an indexer not yet reset): keep this contract's
147
167
  const want = q.address.toLowerCase();
@@ -160,12 +180,26 @@ async function fromIndexer<A>(c: LogswapClient, q: EventLogsQuery): Promise<Hist
160
180
 
161
181
  // ─── the chain path ──────────────────────────────────────────────────────────
162
182
 
183
+ /** How far back the chain path looks for a `latest` tape: a tape is recent by definition. */
184
+ export const LATEST_SCAN_BLOCKS = 5_000n;
185
+
163
186
  async function fromChain<A>(c: LogswapClient, q: EventLogsQuery): Promise<HistoryLog<A>[]> {
164
187
  let fromBlock = q.fromBlock ?? 0n;
165
188
  const toBlock = q.toBlock ?? (await c.public.getBlockNumber());
166
189
  if (q.since !== undefined) fromBlock = await blockAtOrAfter(c, BigInt(q.since), fromBlock, toBlock);
167
- const logs = await c.public.getLogs({ address: q.address, event: q.event, args: q.args, fromBlock, toBlock } as never);
168
- return (logs as unknown as Array<{ args: A; blockNumber: bigint; transactionHash: Hex; logIndex: number }>).map((l) => ({
190
+ if (q.latest !== undefined && toBlock - fromBlock > LATEST_SCAN_BLOCKS) fromBlock = toBlock - LATEST_SCAN_BLOCKS;
191
+ let logs = (await c.public.getLogs({ address: q.address, event: q.event, args: q.args, fromBlock, toBlock } as never)) as unknown as Array<{
192
+ args: A;
193
+ blockNumber: bigint;
194
+ transactionHash: Hex;
195
+ logIndex: number;
196
+ }>;
197
+ if (q.transactionHashes) {
198
+ const want = new Set(q.transactionHashes.map((h) => h.toLowerCase()));
199
+ logs = logs.filter((l) => want.has(l.transactionHash.toLowerCase()));
200
+ }
201
+ if (q.latest !== undefined) logs = logs.slice(-q.latest);
202
+ return logs.map((l) => ({
169
203
  address: q.address,
170
204
  args: l.args,
171
205
  blockNumber: l.blockNumber,