pandora-cli-skills 1.1.46 → 1.1.48

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
@@ -122,6 +122,7 @@ pandora --output json lifecycle resolve --id <lifecycle-id> --confirm
122
122
  - `pandora odds record|history`
123
123
  - `pandora autopilot run|once`
124
124
  - `pandora mirror browse|plan|deploy|verify|lp-explain|hedge-calc|simulate|go|sync|status|close`
125
+ - `mirror browse` supports `--polymarket-tag-id|--polymarket-tag-ids` (aliases `--sport-tag-id|--sport-tag-ids`) for sports-tagged Gamma event discovery.
125
126
  - `pandora polymarket check|approve|preflight|trade`
126
127
  - `pandora resolve`
127
128
  - `pandora lp add|remove|positions`
@@ -200,6 +200,7 @@ Mirror advanced flags (for operator tuning):
200
200
  - `--sync-interval-ms <ms>` on `mirror go` to control auto-sync tick cadence.
201
201
  - `--oracle <address>` / `--factory <address>` on `mirror deploy` and `mirror go` for explicit contract overrides.
202
202
  - `--polymarket-gamma-mock-url <url>` on `mirror browse|plan|verify|go|sync|status` for deterministic mock-source testing.
203
+ - `--polymarket-tag-id <id>` / `--polymarket-tag-ids <csv>` on `mirror browse` (aliases: `--sport-tag-id`, `--sport-tag-ids`) to query sports-tagged Gamma events.
203
204
  - `--no-stream` on `mirror sync` to disable per-tick stdout line streaming in run mode.
204
205
  - `--pid-file <path>` on `mirror sync stop|status` for explicit daemon process selection.
205
206
 
@@ -219,7 +220,7 @@ Mirror advanced flags (for operator tuning):
219
220
  - `pandora export --wallet <0x...> --format csv --out ./trades.csv`
220
221
  - `pandora arbitrage --venues pandora,polymarket --min-spread-pct 2 --cross-venue-only --with-rules --include-similarity`
221
222
  - `pandora autopilot once --market-address <0x...> --side no --amount-usdc 10 --trigger-yes-below 15 --paper`
222
- - `pandora mirror browse --min-yes-pct 20 --max-yes-pct 80 --min-volume-24h 100000 --limit 10`
223
+ - `pandora mirror browse --polymarket-tag-id 82 --min-yes-pct 20 --max-yes-pct 80 --min-volume-24h 100000 --limit 10`
223
224
  - `pandora mirror plan --source polymarket --polymarket-market-id <id> --with-rules --include-similarity`
224
225
  - `pandora mirror go --polymarket-slug <slug> --liquidity-usdc 10 --paper`
225
226
  - `pandora mirror verify --pandora-market-address <0x...> --polymarket-market-id <id> --include-similarity`
@@ -262,7 +263,9 @@ Mirror advanced flags (for operator tuning):
262
263
  - envelope is `ok=true`, `command="trade"`, with tx metadata (`approveTxHash` optional, `buyTxHash` required on success) plus `selectedProbabilityPct` and `riskGuards`.
263
264
 
264
265
  ## Phase 2 limitations
265
- - `trade` currently targets PariMutuel-compatible `buy(bool,uint256,uint256)` markets.
266
+ - `trade` auto-detects market type and uses the correct buy signature:
267
+ - PariMutuel: `buy(bool,uint256,uint256)`
268
+ - AMM: `buy(bool,uint256,uint256,uint256)` (deadline-aware)
266
269
  - `minSharesOut` protection defaults to raw `0` unless explicitly set with `--min-shares-out-raw`.
267
270
  - If indexer odds are unavailable, `quote` still returns a structured payload with `quoteAvailable=false`.
268
271
  - `trade --execute` blocks unquoted execution by default unless `--min-shares-out-raw` or `--allow-unquoted-execute` is provided.
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pandora-cli-skills
3
3
  summary: Canonical skill and operator guide for Pandora CLI including mirror, polymarket, resolve, and LP flows.
4
- version: 1.1.46
4
+ version: 1.1.48
5
5
  ---
6
6
 
7
7
  # Pandora CLI & Skills
@@ -317,7 +317,7 @@ pandora --output json history --wallet <0x...> --limit 50
317
317
  pandora --output json export --wallet <0x...> --format csv --out ./trades.csv
318
318
  pandora --output json arbitrage --venues pandora,polymarket --min-spread-pct 3 --cross-venue-only --with-rules --include-similarity
319
319
  pandora --output json autopilot once --market-address <0x...> --side no --amount-usdc 10 --trigger-yes-below 15 --paper
320
- pandora --output json mirror browse --min-yes-pct 20 --max-yes-pct 80 --min-volume-24h 100000 --limit 10
320
+ pandora --output json mirror browse --polymarket-tag-id 82 --min-yes-pct 20 --max-yes-pct 80 --min-volume-24h 100000 --limit 10
321
321
  pandora --output json mirror plan --source polymarket --polymarket-market-id <id> --with-rules --include-similarity
322
322
  pandora --output json mirror lp-explain --liquidity-usdc 10000 --source-yes-pct 58
323
323
  pandora --output json mirror hedge-calc --reserve-yes-usdc 8 --reserve-no-usdc 12 --excess-no-usdc 2 --polymarket-yes-pct 60
@@ -359,7 +359,9 @@ pandora --output json schema
359
359
  - envelope is `ok=true`, `command="trade"`, with tx metadata (`approveTxHash` optional, `buyTxHash` required on success) plus `selectedProbabilityPct` and `riskGuards`.
360
360
 
361
361
  ## Phase 2 limitations
362
- - `trade` currently targets PariMutuel-compatible `buy(bool,uint256,uint256)` markets.
362
+ - `trade` auto-detects market type and uses the correct buy signature:
363
+ - PariMutuel: `buy(bool,uint256,uint256)`
364
+ - AMM: `buy(bool,uint256,uint256,uint256)` (deadline-aware)
363
365
  - `--min-shares-out-raw` is the explicit slippage guard input for on-chain execution.
364
366
  - If indexer odds are unavailable, `quote` still returns structured output with `quoteAvailable=false`.
365
367
  - `trade --execute` blocks unquoted execution by default unless `--min-shares-out-raw` or `--allow-unquoted-execute` is provided.
@@ -560,7 +562,8 @@ Error envelope:
560
562
  - `{ ok: true, command: "autopilot", data: { schemaVersion, generatedAt, strategyHash, mode, executeLive, stateFile, killSwitchFile, iterationsRequested, iterationsCompleted, stoppedReason?, parameters: { marketAddress, side, amountUsdc, triggerYesBelow?, triggerYesAbove?, intervalMs, cooldownMs, maxAmountUsdc?, maxOpenExposureUsdc, dailySpendCapUsdc, maxTradesPerDay }, state, actionCount, actions[], snapshots[], webhookReports[] } }`
561
563
  - `mirror browse`:
562
564
  - `{ ok: true, command: "mirror.browse", data: { schemaVersion, generatedAt, source, gammaApiError, filters, count, items[], diagnostics[] } }`
563
- - each candidate row can include `existingMirror: { marketAddress, similarity } | null`.
565
+ - `filters` can include `polymarketTagIds[]` when using `--polymarket-tag-id|--polymarket-tag-ids` (or `--sport-tag-id|--sport-tag-ids` aliases).
566
+ - each candidate row can include `eventId`, `eventSlug`, `eventTitle`, and `existingMirror: { marketAddress, similarity } | null`.
564
567
  - `mirror go`:
565
568
  - `{ ok: true, command: "mirror.go", data: { schemaVersion, generatedAt, mode, plan, deploy, verify, sync, polymarketPreflight, suggestedSyncCommand, trustManifest, diagnostics[] } }`
566
569
  - `plan` is the same payload shape as `mirror.plan`; `deploy` is the same payload shape as `mirror.deploy`; `sync` is null unless `--auto-sync` is used.
@@ -91,7 +91,7 @@ function createRunMirrorCommand(deps) {
91
91
  console.log('');
92
92
  console.log('Subcommands:');
93
93
  console.log(
94
- ' browse --min-yes-pct <n> --max-yes-pct <n> --min-volume-24h <n> [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--chain-id <id>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
94
+ ' browse --min-yes-pct <n> --max-yes-pct <n> --min-volume-24h <n> [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--chain-id <id>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
95
95
  );
96
96
  console.log(
97
97
  ' plan --source polymarket --polymarket-market-id <id>|--polymarket-slug <slug> [--chain-id <id>] [--target-slippage-bps <n>] [--turnover-target <n>] [--depth-slippage-bps <n>] [--safety-multiplier <n>] [--min-liquidity-usdc <n>] [--max-liquidity-usdc <n>] [--with-rules] [--include-similarity] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
@@ -22,12 +22,12 @@ module.exports = async function handleMirrorBrowse({ shared, context, deps }) {
22
22
  context.outputMode,
23
23
  'mirror.browse.help',
24
24
  commandHelpPayload(
25
- 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>]',
25
+ 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>]',
26
26
  ),
27
27
  );
28
28
  } else {
29
29
  console.log(
30
- 'Usage: pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>]',
30
+ 'Usage: pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>]',
31
31
  );
32
32
  }
33
33
  return;
@@ -395,6 +395,7 @@ async function browseMirrorMarkets(options = {}) {
395
395
  gammaUrl: options.polymarketGammaUrl,
396
396
  gammaMockUrl: options.polymarketGammaMockUrl,
397
397
  mockUrl: options.polymarketMockUrl,
398
+ polymarketTagIds: Array.isArray(options.polymarketTagIds) ? options.polymarketTagIds : [],
398
399
  timeoutMs: options.timeoutMs,
399
400
  minYesPct: options.minYesPct,
400
401
  maxYesPct: options.maxYesPct,
@@ -39,8 +39,13 @@ function createParseMirrorBrowseFlags(deps) {
39
39
  polymarketGammaUrl: null,
40
40
  polymarketGammaMockUrl: null,
41
41
  polymarketMockUrl: null,
42
+ polymarketTagIds: [],
42
43
  };
43
44
 
45
+ function pushTagId(rawValue, flagName) {
46
+ options.polymarketTagIds.push(parsePositiveInteger(rawValue, flagName));
47
+ }
48
+
44
49
  for (let i = 0; i < args.length; i += 1) {
45
50
  const token = args[i];
46
51
  if (token === '--min-yes-pct') {
@@ -98,6 +103,26 @@ function createParseMirrorBrowseFlags(deps) {
98
103
  i += 1;
99
104
  continue;
100
105
  }
106
+ if (token === '--polymarket-tag-id' || token === '--sport-tag-id') {
107
+ pushTagId(requireFlagValue(args, i, token), token);
108
+ i += 1;
109
+ continue;
110
+ }
111
+ if (token === '--polymarket-tag-ids' || token === '--sport-tag-ids') {
112
+ const raw = requireFlagValue(args, i, token);
113
+ const values = String(raw)
114
+ .split(',')
115
+ .map((value) => value.trim())
116
+ .filter(Boolean);
117
+ if (!values.length) {
118
+ throw new CliError('INVALID_FLAG_VALUE', `${token} must include at least one positive integer tag id.`);
119
+ }
120
+ for (const value of values) {
121
+ pushTagId(value, token);
122
+ }
123
+ i += 1;
124
+ continue;
125
+ }
101
126
  throw new CliError('UNKNOWN_FLAG', `Unknown flag for mirror browse: ${token}`);
102
127
  }
103
128
 
@@ -105,6 +130,10 @@ function createParseMirrorBrowseFlags(deps) {
105
130
  throw new CliError('INVALID_ARGS', '--min-yes-pct cannot be greater than --max-yes-pct.');
106
131
  }
107
132
 
133
+ if (options.polymarketTagIds.length) {
134
+ options.polymarketTagIds = Array.from(new Set(options.polymarketTagIds));
135
+ }
136
+
108
137
  return options;
109
138
  };
110
139
  }
@@ -405,6 +405,9 @@ function normalizeMarketRow(row) {
405
405
  ).trim() || null,
406
406
  slug: String((row && (row.market_slug || row.marketSlug || row.slug)) || '').trim() || null,
407
407
  question: extractQuestionText(row),
408
+ eventId: toStringOrNull(row && (row.event_id || row.eventId)),
409
+ eventSlug: toStringOrNull(row && (row.event_slug || row.eventSlug)),
410
+ eventTitle: toStringOrNull(row && (row.event_title || row.eventTitle)),
408
411
  description: rulesSections.length ? rulesSections.join('\n\n') : null,
409
412
  closeTimestamp: toTimestampSeconds(
410
413
  row &&
@@ -479,6 +482,14 @@ function parseMarketsPayload(payload) {
479
482
  return [];
480
483
  }
481
484
 
485
+ function parseEventsPayload(payload) {
486
+ if (Array.isArray(payload)) return payload;
487
+ if (payload && Array.isArray(payload.events)) return payload.events;
488
+ if (payload && payload.data && Array.isArray(payload.data.events)) return payload.data.events;
489
+ if (payload && Array.isArray(payload.data)) return payload.data;
490
+ return [];
491
+ }
492
+
482
493
  async function fetchJson(url, timeoutMs) {
483
494
  const controller = new AbortController();
484
495
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -530,6 +541,15 @@ function buildGammaUrl(baseUrl, params) {
530
541
  return url.toString();
531
542
  }
532
543
 
544
+ function buildGammaEventsUrl(baseUrl, params) {
545
+ const url = new URL(`${baseUrl}/events`);
546
+ for (const [key, value] of Object.entries(params || {})) {
547
+ if (value === null || value === undefined || value === '') continue;
548
+ url.searchParams.set(key, String(value));
549
+ }
550
+ return url.toString();
551
+ }
552
+
533
553
  async function fetchGammaRows(params, options = {}, diagnostics = []) {
534
554
  const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
535
555
  const gammaUrl = normalizeGammaBaseUrl(options.gammaUrl);
@@ -543,6 +563,120 @@ async function fetchGammaRows(params, options = {}, diagnostics = []) {
543
563
  }
544
564
  }
545
565
 
566
+ function makeMarketDedupeKey(row) {
567
+ return (
568
+ normalizeText(
569
+ row &&
570
+ (row.condition_id ||
571
+ row.conditionId ||
572
+ row.question_id ||
573
+ row.questionId ||
574
+ row.market_id ||
575
+ row.marketId ||
576
+ row.id ||
577
+ row.slug),
578
+ ) || null
579
+ );
580
+ }
581
+
582
+ function flattenEventMarkets(events) {
583
+ const output = [];
584
+ const seen = new Set();
585
+
586
+ for (const event of Array.isArray(events) ? events : []) {
587
+ const markets = Array.isArray(event && event.markets) ? event.markets : [];
588
+ for (const market of markets) {
589
+ if (!market || typeof market !== 'object') continue;
590
+ const dedupeKey = makeMarketDedupeKey(market);
591
+ if (dedupeKey && seen.has(dedupeKey)) continue;
592
+ if (dedupeKey) seen.add(dedupeKey);
593
+ output.push({
594
+ ...market,
595
+ event_id: event && event.id !== undefined ? event.id : null,
596
+ event_slug: event && event.slug ? event.slug : null,
597
+ event_title: event && event.title ? event.title : null,
598
+ event_tags: Array.isArray(event && event.tags) ? event.tags : [],
599
+ });
600
+ }
601
+ }
602
+
603
+ return output;
604
+ }
605
+
606
+ function normalizeTagIdList(input) {
607
+ const values = Array.isArray(input) ? input : [];
608
+ const normalized = [];
609
+ for (const value of values) {
610
+ const numeric = Number(value);
611
+ if (!Number.isFinite(numeric)) continue;
612
+ const asInt = Math.trunc(numeric);
613
+ if (asInt <= 0) continue;
614
+ normalized.push(asInt);
615
+ }
616
+ return Array.from(new Set(normalized));
617
+ }
618
+
619
+ async function fetchGammaRowsByTagIds(params, options = {}, diagnostics = []) {
620
+ const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
621
+ const gammaUrl = normalizeGammaBaseUrl(options.gammaUrl);
622
+ const tagIds = normalizeTagIdList(params && params.tagIds);
623
+ if (!tagIds.length) return [];
624
+
625
+ if (options.gammaMockUrl) {
626
+ try {
627
+ const payload = await fetchJson(options.gammaMockUrl, timeoutMs);
628
+ const events = parseEventsPayload(payload);
629
+ return flattenEventMarkets(events);
630
+ } catch (err) {
631
+ diagnostics.push(`Gamma sports-events request failed (${options.gammaMockUrl}): ${formatNetworkError(err)}`);
632
+ return [];
633
+ }
634
+ }
635
+
636
+ const allRows = [];
637
+ const perTagResults = await Promise.all(
638
+ tagIds.map(async (tagId) => {
639
+ const queryParams = {
640
+ ...(params || {}),
641
+ tag_id: tagId,
642
+ };
643
+ delete queryParams.tagIds;
644
+ const targetUrl = buildGammaEventsUrl(gammaUrl, queryParams);
645
+ try {
646
+ const payload = await fetchJson(targetUrl, timeoutMs);
647
+ const events = parseEventsPayload(payload);
648
+ return {
649
+ rows: flattenEventMarkets(events),
650
+ error: null,
651
+ };
652
+ } catch (err) {
653
+ return {
654
+ rows: [],
655
+ error: `Gamma sports-events request failed (${targetUrl}): ${formatNetworkError(err)}`,
656
+ };
657
+ }
658
+ }),
659
+ );
660
+
661
+ for (const result of perTagResults) {
662
+ if (result.error) {
663
+ diagnostics.push(result.error);
664
+ continue;
665
+ }
666
+ allRows.push(...result.rows);
667
+ }
668
+
669
+ const deduped = [];
670
+ const seen = new Set();
671
+ for (const row of allRows) {
672
+ const dedupeKey = makeMarketDedupeKey(row);
673
+ if (dedupeKey && seen.has(dedupeKey)) continue;
674
+ if (dedupeKey) seen.add(dedupeKey);
675
+ deduped.push(row);
676
+ }
677
+ return deduped;
678
+ }
679
+
546
680
  function extractConditionId(row) {
547
681
  const value = toStringOrNull(
548
682
  row &&
@@ -998,21 +1132,38 @@ async function browsePolymarketMarkets(options = {}) {
998
1132
  const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 12_000;
999
1133
  const requestedLimit = Number.isInteger(Number(options.limit)) && Number(options.limit) > 0 ? Number(options.limit) : 10;
1000
1134
  const scanLimit = Math.max(requestedLimit * 5, 100);
1135
+ const polymarketTagIds = normalizeTagIdList(options.polymarketTagIds);
1136
+ const useSportsEventsEndpoint = polymarketTagIds.length > 0;
1001
1137
 
1002
1138
  let rows = [];
1139
+ let sourceType = options.mockUrl ? 'polymarket:mock' : 'polymarket:gamma';
1003
1140
  if (options.mockUrl) {
1004
1141
  const payload = await fetchMockPayload(options.mockUrl, timeoutMs);
1005
1142
  rows = parseMarketsPayload(payload);
1006
1143
  } else {
1007
- rows = await fetchGammaRows(
1008
- {
1009
- active: true,
1010
- closed: false,
1011
- limit: Math.min(scanLimit, 500),
1012
- },
1013
- options,
1014
- diagnostics,
1015
- );
1144
+ if (useSportsEventsEndpoint) {
1145
+ rows = await fetchGammaRowsByTagIds(
1146
+ {
1147
+ tagIds: polymarketTagIds,
1148
+ active: true,
1149
+ closed: false,
1150
+ limit: Math.min(scanLimit, 500),
1151
+ },
1152
+ options,
1153
+ diagnostics,
1154
+ );
1155
+ sourceType = 'polymarket:gamma-events';
1156
+ } else {
1157
+ rows = await fetchGammaRows(
1158
+ {
1159
+ active: true,
1160
+ closed: false,
1161
+ limit: Math.min(scanLimit, 500),
1162
+ },
1163
+ options,
1164
+ diagnostics,
1165
+ );
1166
+ }
1016
1167
  }
1017
1168
 
1018
1169
  const minYesPct = toOptionalNumber(options.minYesPct);
@@ -1039,6 +1190,9 @@ async function browsePolymarketMarkets(options = {}) {
1039
1190
  const items = filtered.slice(0, requestedLimit).map((item) => ({
1040
1191
  marketId: item.marketId,
1041
1192
  slug: item.slug,
1193
+ eventId: item.eventId,
1194
+ eventSlug: item.eventSlug,
1195
+ eventTitle: item.eventTitle,
1042
1196
  question: item.question,
1043
1197
  closeTimestamp: item.closeTimestamp,
1044
1198
  yesPct: item.yesPct,
@@ -1048,16 +1202,16 @@ async function browsePolymarketMarkets(options = {}) {
1048
1202
  active: item.active,
1049
1203
  resolved: item.resolved,
1050
1204
  url: item.url,
1051
- sourceType: item.source || (options.mockUrl ? 'polymarket:mock' : 'polymarket:gamma'),
1205
+ sourceType: item.source || sourceType,
1052
1206
  }));
1053
1207
 
1054
1208
  const gammaApiError =
1055
- diagnostics.find((line) => /^Gamma request failed/i.test(String(line || ''))) || null;
1209
+ diagnostics.find((line) => /^Gamma( sports-events)? request failed/i.test(String(line || ''))) || null;
1056
1210
 
1057
1211
  return {
1058
1212
  schemaVersion: '1.0.0',
1059
1213
  generatedAt: new Date().toISOString(),
1060
- source: options.mockUrl ? 'polymarket:mock' : 'polymarket:gamma',
1214
+ source: sourceType,
1061
1215
  filters: {
1062
1216
  minYesPct,
1063
1217
  maxYesPct,
@@ -1066,6 +1220,7 @@ async function browsePolymarketMarkets(options = {}) {
1066
1220
  closesBefore,
1067
1221
  questionContains: options.questionContains || null,
1068
1222
  limit: requestedLimit,
1223
+ polymarketTagIds,
1069
1224
  },
1070
1225
  count: items.length,
1071
1226
  items,
@@ -264,6 +264,13 @@ function buildCommandDescriptors() {
264
264
  emits: ['sports.resolve.plan', 'sports.help'],
265
265
  dataSchema: '#/definitions/SportsResolvePlanPayload',
266
266
  }),
267
+ 'mirror.browse': commandDescriptor({
268
+ summary: 'Browse Polymarket mirror candidates with optional sports tag filters.',
269
+ usage:
270
+ 'pandora [--output table|json] mirror browse [--min-yes-pct <n>] [--max-yes-pct <n>] [--min-volume-24h <n>] [--closes-after <date>] [--closes-before <date>] [--question-contains <text>] [--limit <n>] [--chain-id <id>] [--polymarket-tag-id <id>] [--polymarket-tag-ids <csv>] [--sport-tag-id <id>] [--sport-tag-ids <csv>] [--polymarket-gamma-url <url>] [--polymarket-gamma-mock-url <url>] [--polymarket-mock-url <url>]',
271
+ emits: ['mirror.browse', 'mirror.browse.help'],
272
+ dataSchema: '#/definitions/MirrorBrowsePayload',
273
+ }),
267
274
  'mirror.plan': commandDescriptor({
268
275
  summary: 'Generate mirror sizing/distribution plan from Polymarket source.',
269
276
  usage:
@@ -568,6 +575,9 @@ function buildSchemaPayload() {
568
575
  mode: { enum: ['dry-run', 'execute'] },
569
576
  status: { type: 'string' },
570
577
  marketAddress: { type: 'string' },
578
+ marketType: { type: ['string', 'null'] },
579
+ buySignature: { type: ['string', 'null'] },
580
+ ammDeadlineEpoch: { type: ['string', 'null'] },
571
581
  side: { enum: ['yes', 'no'] },
572
582
  amountUsdc: { type: 'number' },
573
583
  quote: { type: 'object' },
@@ -809,6 +819,19 @@ function buildSchemaPayload() {
809
819
  generatedAt: { type: 'string', format: 'date-time' },
810
820
  },
811
821
  },
822
+ MirrorBrowsePayload: {
823
+ type: 'object',
824
+ properties: {
825
+ source: { type: 'string' },
826
+ gammaApiError: { type: ['string', 'null'] },
827
+ filters: { type: 'object' },
828
+ count: { type: 'integer' },
829
+ items: { type: 'array', items: { type: 'object' } },
830
+ diagnostics: { type: 'array', items: { type: 'string' } },
831
+ schemaVersion: { type: 'string' },
832
+ generatedAt: { type: 'string', format: 'date-time' },
833
+ },
834
+ },
812
835
  MirrorPlanPayload: {
813
836
  type: 'object',
814
837
  properties: {
@@ -117,6 +117,9 @@ function createRunTradeCommand(deps) {
117
117
  },
118
118
  chainId: execution.chainId,
119
119
  marketAddress: options.marketAddress,
120
+ marketType: execution.marketType || null,
121
+ buySignature: execution.buySignature || null,
122
+ ammDeadlineEpoch: execution.ammDeadlineEpoch || null,
120
123
  side: options.side,
121
124
  amountUsdc: options.amountUsdc,
122
125
  amountRaw: execution.amountRaw,
@@ -0,0 +1,203 @@
1
+ const PARI_MUTUEL_BUY_ABI = [
2
+ {
3
+ name: 'buy',
4
+ type: 'function',
5
+ stateMutability: 'nonpayable',
6
+ inputs: [
7
+ { name: 'isYes', type: 'bool' },
8
+ { name: 'collateralAmount', type: 'uint256' },
9
+ { name: 'minSharesOut', type: 'uint256' },
10
+ ],
11
+ outputs: [{ name: 'sharesOut', type: 'uint256' }],
12
+ },
13
+ ];
14
+
15
+ const PREDICTION_AMM_BUY_ABI = [
16
+ {
17
+ name: 'buy',
18
+ type: 'function',
19
+ stateMutability: 'nonpayable',
20
+ inputs: [
21
+ { name: 'isYes', type: 'bool' },
22
+ { name: 'collateralAmount', type: 'uint256' },
23
+ { name: 'minSharesOut', type: 'uint256' },
24
+ { name: 'deadline', type: 'uint256' },
25
+ ],
26
+ outputs: [{ name: 'sharesOut', type: 'uint256' }],
27
+ },
28
+ ];
29
+
30
+ const PARI_MUTUEL_MARKER_ABI = [
31
+ {
32
+ type: 'function',
33
+ name: 'curveFlattener',
34
+ stateMutability: 'view',
35
+ inputs: [],
36
+ outputs: [{ type: 'uint8' }],
37
+ },
38
+ ];
39
+
40
+ const PREDICTION_AMM_MARKER_ABI = [
41
+ {
42
+ type: 'function',
43
+ name: 'tradingFee',
44
+ stateMutability: 'view',
45
+ inputs: [],
46
+ outputs: [{ type: 'uint24' }],
47
+ },
48
+ ];
49
+
50
+ const DEFAULT_AMM_TRADE_DEADLINE_OFFSET_SEC = 15 * 60;
51
+
52
+ function createTradeTypeError(code, message, details = undefined) {
53
+ const err = new Error(message);
54
+ err.code = code;
55
+ if (details !== undefined) {
56
+ err.details = details;
57
+ }
58
+ return err;
59
+ }
60
+
61
+ /**
62
+ * Detects supported Pandora market type by probing stable view-method markers.
63
+ * @param {{readContract: Function}} publicClient
64
+ * @param {`0x${string}`} marketAddress
65
+ * @returns {Promise<{marketType:'parimutuel'|'amm', detectedBy:string}>}
66
+ */
67
+ async function detectTradeMarketType(publicClient, marketAddress) {
68
+ let pariError = null;
69
+ try {
70
+ await publicClient.readContract({
71
+ address: marketAddress,
72
+ abi: PARI_MUTUEL_MARKER_ABI,
73
+ functionName: 'curveFlattener',
74
+ });
75
+ return {
76
+ marketType: 'parimutuel',
77
+ detectedBy: 'curveFlattener',
78
+ };
79
+ } catch (err) {
80
+ pariError = err;
81
+ }
82
+
83
+ let ammError = null;
84
+ try {
85
+ await publicClient.readContract({
86
+ address: marketAddress,
87
+ abi: PREDICTION_AMM_MARKER_ABI,
88
+ functionName: 'tradingFee',
89
+ });
90
+ return {
91
+ marketType: 'amm',
92
+ detectedBy: 'tradingFee',
93
+ };
94
+ } catch (err) {
95
+ ammError = err;
96
+ }
97
+
98
+ throw createTradeTypeError(
99
+ 'UNSUPPORTED_MARKET_TRADE_INTERFACE',
100
+ 'Market does not expose a supported Pandora trade interface.',
101
+ {
102
+ marketAddress,
103
+ attemptedMarkers: ['curveFlattener', 'tradingFee'],
104
+ markerErrors: {
105
+ parimutuel: pariError && pariError.message ? pariError.message : String(pariError),
106
+ amm: ammError && ammError.message ? ammError.message : String(ammError),
107
+ },
108
+ },
109
+ );
110
+ }
111
+
112
+ function toEpochSeconds(value, fallback) {
113
+ const numeric = Number(value);
114
+ if (Number.isFinite(numeric)) {
115
+ const truncated = Math.trunc(numeric);
116
+ if (truncated >= 0) return truncated;
117
+ }
118
+ return fallback;
119
+ }
120
+
121
+ /**
122
+ * Builds the market-specific buy call shape.
123
+ * @param {{
124
+ * marketType: 'parimutuel'|'amm',
125
+ * side: 'yes'|'no',
126
+ * amountRaw: bigint,
127
+ * minSharesOutRaw: bigint,
128
+ * nowEpochSec?: number,
129
+ * ammDeadlineOffsetSec?: number,
130
+ * }} input
131
+ * @returns {{marketType:'parimutuel'|'amm', abi: object[], functionName: 'buy', args: (boolean|bigint)[], signature: string, ammDeadlineEpoch?: string}}
132
+ */
133
+ function buildTradeBuyCall(input) {
134
+ const marketType = String(input.marketType || '').toLowerCase();
135
+ const isYes = String(input.side || '').toLowerCase() === 'yes';
136
+ const amountRaw = input.amountRaw;
137
+ const minSharesOutRaw = input.minSharesOutRaw;
138
+
139
+ if (marketType === 'parimutuel') {
140
+ return {
141
+ marketType: 'parimutuel',
142
+ abi: PARI_MUTUEL_BUY_ABI,
143
+ functionName: 'buy',
144
+ args: [isYes, amountRaw, minSharesOutRaw],
145
+ signature: 'buy(bool,uint256,uint256)',
146
+ ammDeadlineEpoch: null,
147
+ };
148
+ }
149
+
150
+ if (marketType === 'amm') {
151
+ const nowEpochSec = toEpochSeconds(input.nowEpochSec, Math.trunc(Date.now() / 1000));
152
+ const offsetSec = toEpochSeconds(input.ammDeadlineOffsetSec, DEFAULT_AMM_TRADE_DEADLINE_OFFSET_SEC);
153
+ const deadlineEpoch = BigInt(nowEpochSec + Math.max(1, offsetSec));
154
+ return {
155
+ marketType: 'amm',
156
+ abi: PREDICTION_AMM_BUY_ABI,
157
+ functionName: 'buy',
158
+ args: [isYes, amountRaw, minSharesOutRaw, deadlineEpoch],
159
+ signature: 'buy(bool,uint256,uint256,uint256)',
160
+ ammDeadlineEpoch: deadlineEpoch.toString(),
161
+ };
162
+ }
163
+
164
+ throw createTradeTypeError('UNSUPPORTED_MARKET_TYPE', `Unsupported market type for trade execution: ${marketType}`);
165
+ }
166
+
167
+ /**
168
+ * Resolves market type then constructs the correct buy call descriptor.
169
+ * @param {{
170
+ * publicClient: { readContract: Function },
171
+ * marketAddress: `0x${string}`,
172
+ * side: 'yes'|'no',
173
+ * amountRaw: bigint,
174
+ * minSharesOutRaw: bigint,
175
+ * nowEpochSec?: number,
176
+ * ammDeadlineOffsetSec?: number,
177
+ * }} input
178
+ * @returns {Promise<{marketType:'parimutuel'|'amm', detectedBy:string, abi: object[], functionName: 'buy', args: (boolean|bigint)[], signature: string, ammDeadlineEpoch?: string}>}
179
+ */
180
+ async function resolveTradeBuyCall(input) {
181
+ const detected = await detectTradeMarketType(input.publicClient, input.marketAddress);
182
+ const call = buildTradeBuyCall({
183
+ marketType: detected.marketType,
184
+ side: input.side,
185
+ amountRaw: input.amountRaw,
186
+ minSharesOutRaw: input.minSharesOutRaw,
187
+ nowEpochSec: input.nowEpochSec,
188
+ ammDeadlineOffsetSec: input.ammDeadlineOffsetSec,
189
+ });
190
+ return {
191
+ ...call,
192
+ detectedBy: detected.detectedBy,
193
+ };
194
+ }
195
+
196
+ module.exports = {
197
+ PARI_MUTUEL_BUY_ABI,
198
+ PREDICTION_AMM_BUY_ABI,
199
+ DEFAULT_AMM_TRADE_DEADLINE_OFFSET_SEC,
200
+ detectTradeMarketType,
201
+ buildTradeBuyCall,
202
+ resolveTradeBuyCall,
203
+ };
package/cli/pandora.cjs CHANGED
@@ -60,6 +60,7 @@ const { createRunOddsCommand } = require('./lib/odds_command_service.cjs');
60
60
  const { createRunSportsCommand } = require('./lib/sports_command_service.cjs');
61
61
  const { createRunRiskCommand } = require('./lib/risk_command_service.cjs');
62
62
  const { createRunModelCommand } = require('./lib/model_command_service.cjs');
63
+ const { resolveTradeBuyCall } = require('./lib/trade_market_type_service.cjs');
63
64
  const {
64
65
  DEFAULT_INDEXER_URL: SHARED_DEFAULT_INDEXER_URL,
65
66
  DEFAULT_RPC_BY_CHAIN_ID,
@@ -598,20 +599,6 @@ const ERC20_ABI = [
598
599
  outputs: [{ type: 'uint256' }],
599
600
  },
600
601
  ];
601
- const PARI_MUTUEL_ABI = [
602
- {
603
- name: 'buy',
604
- type: 'function',
605
- stateMutability: 'nonpayable',
606
- inputs: [
607
- { name: 'isYes', type: 'bool' },
608
- { name: 'collateralAmount', type: 'uint256' },
609
- { name: 'minSharesOut', type: 'uint256' },
610
- ],
611
- outputs: [{ name: 'sharesOut', type: 'uint256' }],
612
- },
613
- ];
614
-
615
602
  const MARKET_DIRECT_ODDS_FIELDS = [
616
603
  { yesField: 'yesPct', noField: 'noPct', source: 'direct:yesPct/noPct' },
617
604
  { yesField: 'yesOdds', noField: 'noOdds', source: 'direct:yesOdds/noOdds' },
@@ -897,10 +884,10 @@ Usage:
897
884
 
898
885
  Notes:
899
886
  - --dry-run prints the execution plan and quote without sending transactions.
900
- - --execute performs allowance check, optional USDC approve, then calls buy(bool,uint256,uint256).
887
+ - --execute performs allowance check, optional USDC approve, then calls market buy() using the detected market ABI.
901
888
  - --max-amount-usdc and probability guard flags fail fast before execution.
902
889
  - --execute requires a quote by default unless --min-shares-out-raw or --allow-unquoted-execute is set.
903
- - Current execute path targets PariMutuel-compatible markets.
890
+ - Supports both PariMutuel and AMM market buy signatures.
904
891
  `);
905
892
  }
906
893
 
@@ -3604,6 +3591,28 @@ async function executeTradeOnchain(options) {
3604
3591
  });
3605
3592
  };
3606
3593
 
3594
+ let buyCall;
3595
+ try {
3596
+ buyCall = await resolveTradeBuyCall({
3597
+ publicClient,
3598
+ marketAddress: options.marketAddress,
3599
+ side: options.side,
3600
+ amountRaw,
3601
+ minSharesOutRaw,
3602
+ });
3603
+ } catch (error) {
3604
+ if (error && error.code) {
3605
+ throw new CliError(
3606
+ error.code,
3607
+ error.message || 'Unsupported market trade interface.',
3608
+ error.details,
3609
+ );
3610
+ }
3611
+ await decodeTradeError(error, 'TRADE_MARKET_TYPE_RESOLUTION_FAILED', 'Unable to resolve market trade interface.', {
3612
+ stage: 'market-type-resolve',
3613
+ });
3614
+ }
3615
+
3607
3616
  let allowance;
3608
3617
  try {
3609
3618
  allowance = await publicClient.readContract({
@@ -3660,9 +3669,9 @@ async function executeTradeOnchain(options) {
3660
3669
  const buySimulation = await publicClient.simulateContract({
3661
3670
  account,
3662
3671
  address: options.marketAddress,
3663
- abi: PARI_MUTUEL_ABI,
3664
- functionName: 'buy',
3665
- args: [options.side === 'yes', amountRaw, minSharesOutRaw],
3672
+ abi: buyCall.abi,
3673
+ functionName: buyCall.functionName,
3674
+ args: buyCall.args,
3666
3675
  });
3667
3676
  buyGasEstimate =
3668
3677
  buySimulation && buySimulation.request && buySimulation.request.gas
@@ -3675,6 +3684,9 @@ async function executeTradeOnchain(options) {
3675
3684
  await decodeTradeError(error, 'TRADE_EXECUTION_FAILED', 'Buy transaction failed.', {
3676
3685
  stage: 'buy',
3677
3686
  buyTxHash,
3687
+ marketType: buyCall ? buyCall.marketType : null,
3688
+ buySignature: buyCall ? buyCall.signature : null,
3689
+ ammDeadlineEpoch: buyCall && buyCall.ammDeadlineEpoch ? buyCall.ammDeadlineEpoch : null,
3678
3690
  });
3679
3691
  }
3680
3692
 
@@ -3684,6 +3696,9 @@ async function executeTradeOnchain(options) {
3684
3696
  rpcUrl: runtime.rpcUrl,
3685
3697
  account: account.address,
3686
3698
  usdc: runtime.usdcAddress,
3699
+ marketType: buyCall.marketType,
3700
+ buySignature: buyCall.signature,
3701
+ ammDeadlineEpoch: buyCall.ammDeadlineEpoch,
3687
3702
  amountRaw: amountRaw.toString(),
3688
3703
  minSharesOutRaw: minSharesOutRaw.toString(),
3689
3704
  approveTxHash,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pandora-cli-skills",
3
- "version": "1.1.46",
3
+ "version": "1.1.48",
4
4
  "description": "Pandora CLI & Skills",
5
5
  "main": "cli/pandora.cjs",
6
6
  "bin": {
@@ -1090,6 +1090,9 @@ test('schema command returns envelope schema plus command descriptors', () => {
1090
1090
  assert.ok(payload.data.commandDescriptors.quote.emits.includes('quote'));
1091
1091
  assert.ok(payload.data.commandDescriptors.trade);
1092
1092
  assert.equal(payload.data.commandDescriptors.trade.dataSchema, '#/definitions/TradePayload');
1093
+ assert.ok(payload.data.commandDescriptors['mirror.browse']);
1094
+ assert.equal(payload.data.commandDescriptors['mirror.browse'].dataSchema, '#/definitions/MirrorBrowsePayload');
1095
+ assert.match(payload.data.commandDescriptors['mirror.browse'].usage, /--polymarket-tag-id/);
1093
1096
  assert.ok(payload.data.commandDescriptors['mirror.plan']);
1094
1097
  assert.equal(payload.data.commandDescriptors['mirror.plan'].dataSchema, '#/definitions/MirrorPlanPayload');
1095
1098
  assert.ok(payload.data.commandDescriptors['risk.show']);
@@ -1150,6 +1153,7 @@ test('schema command returns envelope schema plus command descriptors', () => {
1150
1153
  assert.ok(payload.data.definitions.ModelCorrelationPayload);
1151
1154
  assert.ok(payload.data.definitions.ModelDiagnosePayload);
1152
1155
  assert.ok(payload.data.definitions.ErrorRecoveryPayload);
1156
+ assert.ok(payload.data.definitions.MirrorBrowsePayload);
1153
1157
  });
1154
1158
 
1155
1159
  test('schema command rejects unknown trailing flags', () => {
@@ -4825,6 +4829,24 @@ test('mirror browse rejects invalid calendar rollover dates', () => {
4825
4829
  assert.match(payload.error.message, /real calendar date/);
4826
4830
  });
4827
4831
 
4832
+ test('mirror browse rejects invalid tag id values', () => {
4833
+ const result = runCli(['--output', 'json', 'mirror', 'browse', '--polymarket-tag-id', '0']);
4834
+ assert.equal(result.status, 1);
4835
+ const payload = parseJsonOutput(result);
4836
+ assert.equal(payload.ok, false);
4837
+ assert.equal(payload.error.code, 'INVALID_FLAG_VALUE');
4838
+ assert.match(payload.error.message, /--polymarket-tag-id must be a positive integer/i);
4839
+ });
4840
+
4841
+ test('mirror browse rejects empty tag-id csv values', () => {
4842
+ const result = runCli(['--output', 'json', 'mirror', 'browse', '--polymarket-tag-ids', ', ,']);
4843
+ assert.equal(result.status, 1);
4844
+ const payload = parseJsonOutput(result);
4845
+ assert.equal(payload.ok, false);
4846
+ assert.equal(payload.error.code, 'INVALID_FLAG_VALUE');
4847
+ assert.match(payload.error.message, /must include at least one positive integer tag id/i);
4848
+ });
4849
+
4828
4850
  test('boolean flags with --key=false do not silently flip behavior', () => {
4829
4851
  const result = runCli(['--output', 'json', 'scan', '--active=false']);
4830
4852
  assert.equal(result.status, 1);
@@ -4924,6 +4946,83 @@ test('mirror browse returns candidate markets with existing mirror hint', async
4924
4946
  }
4925
4947
  });
4926
4948
 
4949
+ test('mirror browse supports sports tag filters via gamma events endpoint', async () => {
4950
+ const gamma = await startJsonHttpServer((request) => {
4951
+ const parsed = new URL(request.url || '/', 'http://127.0.0.1');
4952
+ if (parsed.pathname !== '/events') {
4953
+ return { status: 404, body: { error: 'not found' } };
4954
+ }
4955
+
4956
+ const tagId = parsed.searchParams.get('tag_id');
4957
+ if (tagId !== '82') {
4958
+ return { body: { events: [] } };
4959
+ }
4960
+
4961
+ return {
4962
+ body: {
4963
+ events: [
4964
+ {
4965
+ id: 'evt-epl-1',
4966
+ slug: 'everton-v-burnley',
4967
+ title: 'Everton vs Burnley',
4968
+ markets: [
4969
+ {
4970
+ condition_id: 'poly-epl-c1',
4971
+ market_slug: 'everton-v-burnley-home',
4972
+ question: 'Will Everton beat Burnley?',
4973
+ end_date_iso: FIXED_MIRROR_CLOSE_ISO,
4974
+ active: true,
4975
+ closed: false,
4976
+ volume24hr: 550000,
4977
+ tokens: [
4978
+ { outcome: 'Yes', price: '0.605', token_id: 'poly-epl-yes-1' },
4979
+ { outcome: 'No', price: '0.395', token_id: 'poly-epl-no-1' },
4980
+ ],
4981
+ },
4982
+ ],
4983
+ },
4984
+ ],
4985
+ },
4986
+ };
4987
+ });
4988
+
4989
+ try {
4990
+ const result = await runCliAsync([
4991
+ '--output',
4992
+ 'json',
4993
+ 'mirror',
4994
+ 'browse',
4995
+ '--skip-dotenv',
4996
+ '--polymarket-gamma-url',
4997
+ gamma.url,
4998
+ '--polymarket-tag-id',
4999
+ '82',
5000
+ '--limit',
5001
+ '5',
5002
+ ]);
5003
+
5004
+ assert.equal(result.status, 0);
5005
+ const payload = parseJsonOutput(result);
5006
+ assert.equal(payload.ok, true);
5007
+ assert.equal(payload.command, 'mirror.browse');
5008
+ assert.equal(payload.data.source, 'polymarket:gamma-events');
5009
+ assert.deepEqual(payload.data.filters.polymarketTagIds, [82]);
5010
+ assert.equal(payload.data.count, 1);
5011
+ assert.equal(payload.data.items[0].eventSlug, 'everton-v-burnley');
5012
+ assert.equal(payload.data.items[0].eventTitle, 'Everton vs Burnley');
5013
+ assert.equal(payload.data.items[0].eventId, 'evt-epl-1');
5014
+
5015
+ const eventRequest = gamma.requests.find((entry) => String(entry.url || '').startsWith('/events?'));
5016
+ assert.ok(eventRequest);
5017
+ const parsed = new URL(eventRequest.url, 'http://127.0.0.1');
5018
+ assert.equal(parsed.searchParams.get('tag_id'), '82');
5019
+ assert.equal(parsed.searchParams.get('active'), 'true');
5020
+ assert.equal(parsed.searchParams.get('closed'), 'false');
5021
+ } finally {
5022
+ await gamma.close();
5023
+ }
5024
+ });
5025
+
4927
5026
  test('mirror sync accepts --market-address with --dry-run mode alias', async () => {
4928
5027
  const tempDir = createTempDir('pandora-mirror-sync-aliases-');
4929
5028
  const stateFile = path.join(tempDir, 'mirror-state.json');
@@ -1027,6 +1027,124 @@ test('browsePolymarketMarkets filters mock payload deterministically', async ()
1027
1027
  }
1028
1028
  });
1029
1029
 
1030
+ test('browsePolymarketMarkets uses gamma events endpoint for tag-id sports discovery', async () => {
1031
+ const requests = [];
1032
+ const server = http.createServer((req, res) => {
1033
+ requests.push(req.url || '/');
1034
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1');
1035
+ const tagId = parsed.searchParams.get('tag_id');
1036
+
1037
+ res.statusCode = 200;
1038
+ res.setHeader('content-type', 'application/json');
1039
+
1040
+ if (parsed.pathname !== '/events') {
1041
+ res.end(JSON.stringify({ events: [] }));
1042
+ return;
1043
+ }
1044
+
1045
+ if (tagId === '82') {
1046
+ res.end(
1047
+ JSON.stringify({
1048
+ events: [
1049
+ {
1050
+ id: 'evt-82',
1051
+ slug: 'everton-v-burnley',
1052
+ title: 'Everton vs Burnley',
1053
+ markets: [
1054
+ {
1055
+ condition_id: 'sports-c1',
1056
+ market_slug: 'everton-v-burnley-home',
1057
+ question: 'Will Everton beat Burnley?',
1058
+ end_date_iso: '2030-03-09T16:00:00Z',
1059
+ active: true,
1060
+ closed: false,
1061
+ volume24hr: 500000,
1062
+ tokens: [
1063
+ { outcome: 'Yes', price: '0.605', token_id: 'yes-sports-1' },
1064
+ { outcome: 'No', price: '0.395', token_id: 'no-sports-1' },
1065
+ ],
1066
+ },
1067
+ ],
1068
+ },
1069
+ ],
1070
+ }),
1071
+ );
1072
+ return;
1073
+ }
1074
+
1075
+ if (tagId === '100350') {
1076
+ res.end(
1077
+ JSON.stringify({
1078
+ events: [
1079
+ {
1080
+ id: 'evt-100350',
1081
+ slug: 'leeds-v-sunderland',
1082
+ title: 'Leeds vs Sunderland',
1083
+ markets: [
1084
+ {
1085
+ // Duplicate condition id should be deduped across tag-id scans.
1086
+ condition_id: 'sports-c1',
1087
+ market_slug: 'duplicate-market-ignored',
1088
+ question: 'Duplicate row should be ignored',
1089
+ end_date_iso: '2030-03-09T16:00:00Z',
1090
+ active: true,
1091
+ closed: false,
1092
+ volume24hr: 1,
1093
+ tokens: [
1094
+ { outcome: 'Yes', price: '0.5', token_id: 'dup-yes' },
1095
+ { outcome: 'No', price: '0.5', token_id: 'dup-no' },
1096
+ ],
1097
+ },
1098
+ {
1099
+ condition_id: 'sports-c2',
1100
+ market_slug: 'leeds-v-sunderland-home',
1101
+ question: 'Will Leeds beat Sunderland?',
1102
+ end_date_iso: '2030-03-09T16:00:00Z',
1103
+ active: true,
1104
+ closed: false,
1105
+ volume24hr: 400000,
1106
+ tokens: [
1107
+ { outcome: 'Yes', price: '0.495', token_id: 'yes-sports-2' },
1108
+ { outcome: 'No', price: '0.505', token_id: 'no-sports-2' },
1109
+ ],
1110
+ },
1111
+ ],
1112
+ },
1113
+ ],
1114
+ }),
1115
+ );
1116
+ return;
1117
+ }
1118
+
1119
+ res.end(JSON.stringify({ events: [] }));
1120
+ });
1121
+
1122
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
1123
+ const { port } = server.address();
1124
+ const gammaUrl = `http://127.0.0.1:${port}`;
1125
+
1126
+ try {
1127
+ const payload = await browsePolymarketMarkets({
1128
+ gammaUrl,
1129
+ polymarketTagIds: [82, 100350],
1130
+ limit: 10,
1131
+ });
1132
+
1133
+ assert.equal(payload.source, 'polymarket:gamma-events');
1134
+ assert.deepEqual(payload.filters.polymarketTagIds, [82, 100350]);
1135
+ assert.equal(payload.count, 2);
1136
+ assert.equal(payload.items[0].eventSlug, 'everton-v-burnley');
1137
+ assert.equal(payload.items[1].eventSlug, 'leeds-v-sunderland');
1138
+ assert.equal(payload.items[0].eventTitle, 'Everton vs Burnley');
1139
+ assert.equal(payload.items[0].eventId, 'evt-82');
1140
+
1141
+ const eventRequests = requests.filter((entry) => String(entry).startsWith('/events?'));
1142
+ assert.equal(eventRequests.length, 2);
1143
+ } finally {
1144
+ await new Promise((resolve) => server.close(resolve));
1145
+ }
1146
+ });
1147
+
1030
1148
  test('computeApprovalDiff deterministically marks missing allowance/operator checks', () => {
1031
1149
  const payload = computeApprovalDiff({
1032
1150
  ownerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@@ -0,0 +1,109 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const {
5
+ DEFAULT_AMM_TRADE_DEADLINE_OFFSET_SEC,
6
+ detectTradeMarketType,
7
+ buildTradeBuyCall,
8
+ resolveTradeBuyCall,
9
+ } = require('../../cli/lib/trade_market_type_service.cjs');
10
+
11
+ const MARKET = '0x1111111111111111111111111111111111111111';
12
+
13
+ test('detectTradeMarketType resolves parimutuel marker first', async () => {
14
+ const publicClient = {
15
+ async readContract(request) {
16
+ if (request.functionName === 'curveFlattener') return 7n;
17
+ throw new Error('unexpected');
18
+ },
19
+ };
20
+
21
+ const detected = await detectTradeMarketType(publicClient, MARKET);
22
+ assert.equal(detected.marketType, 'parimutuel');
23
+ assert.equal(detected.detectedBy, 'curveFlattener');
24
+ });
25
+
26
+ test('detectTradeMarketType falls back to amm marker', async () => {
27
+ const publicClient = {
28
+ async readContract(request) {
29
+ if (request.functionName === 'curveFlattener') throw new Error('function selector was not recognized');
30
+ if (request.functionName === 'tradingFee') return 3000n;
31
+ throw new Error('unexpected');
32
+ },
33
+ };
34
+
35
+ const detected = await detectTradeMarketType(publicClient, MARKET);
36
+ assert.equal(detected.marketType, 'amm');
37
+ assert.equal(detected.detectedBy, 'tradingFee');
38
+ });
39
+
40
+ test('detectTradeMarketType throws unsupported interface when neither marker exists', async () => {
41
+ const publicClient = {
42
+ async readContract() {
43
+ throw new Error('reverted');
44
+ },
45
+ };
46
+
47
+ await assert.rejects(
48
+ () => detectTradeMarketType(publicClient, MARKET),
49
+ (error) => {
50
+ assert.equal(error.code, 'UNSUPPORTED_MARKET_TRADE_INTERFACE');
51
+ assert.equal(error.details.marketAddress, MARKET);
52
+ return true;
53
+ },
54
+ );
55
+ });
56
+
57
+ test('buildTradeBuyCall uses 3-arg buy for parimutuel', () => {
58
+ const call = buildTradeBuyCall({
59
+ marketType: 'parimutuel',
60
+ side: 'yes',
61
+ amountRaw: 1_000_000n,
62
+ minSharesOutRaw: 0n,
63
+ });
64
+
65
+ assert.equal(call.signature, 'buy(bool,uint256,uint256)');
66
+ assert.deepEqual(call.args, [true, 1_000_000n, 0n]);
67
+ assert.equal(call.ammDeadlineEpoch, null);
68
+ });
69
+
70
+ test('buildTradeBuyCall uses 4-arg deadline buy for amm', () => {
71
+ const nowEpochSec = 1_700_000_000;
72
+ const call = buildTradeBuyCall({
73
+ marketType: 'amm',
74
+ side: 'no',
75
+ amountRaw: 2_500_000n,
76
+ minSharesOutRaw: 12n,
77
+ nowEpochSec,
78
+ });
79
+
80
+ assert.equal(call.signature, 'buy(bool,uint256,uint256,uint256)');
81
+ assert.equal(call.args.length, 4);
82
+ assert.deepEqual(call.args.slice(0, 3), [false, 2_500_000n, 12n]);
83
+ assert.equal(call.ammDeadlineEpoch, String(nowEpochSec + DEFAULT_AMM_TRADE_DEADLINE_OFFSET_SEC));
84
+ });
85
+
86
+ test('resolveTradeBuyCall composes detection and call creation', async () => {
87
+ const publicClient = {
88
+ async readContract(request) {
89
+ if (request.functionName === 'curveFlattener') throw new Error('not pari');
90
+ if (request.functionName === 'tradingFee') return 500n;
91
+ throw new Error('unexpected');
92
+ },
93
+ };
94
+
95
+ const call = await resolveTradeBuyCall({
96
+ publicClient,
97
+ marketAddress: MARKET,
98
+ side: 'yes',
99
+ amountRaw: 9n,
100
+ minSharesOutRaw: 1n,
101
+ nowEpochSec: 1000,
102
+ ammDeadlineOffsetSec: 5,
103
+ });
104
+
105
+ assert.equal(call.marketType, 'amm');
106
+ assert.equal(call.detectedBy, 'tradingFee');
107
+ assert.equal(call.signature, 'buy(bool,uint256,uint256,uint256)');
108
+ assert.deepEqual(call.args, [true, 9n, 1n, 1005n]);
109
+ });