366 lines
19 KiB
TypeScript
366 lines
19 KiB
TypeScript
// file: kb_demo_app/frontend/ts/demo3.ts
|
||
|
||
import * as bootstrap from "bootstrap";
|
||
import "simplebar";
|
||
import ResizeObserver from "resize-observer-polyfill";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { debug, takeoverConsole } from "@fltsci/tauri-plugin-tracing";
|
||
import type { Demo3LocalDexCorpusSearchRequest } from "./bindings/Demo3LocalDexCorpusSearchRequest.ts";
|
||
import type { Demo3LocalDexCorpusSearchPayload } from "./bindings/Demo3LocalDexCorpusSearchPayload.ts";
|
||
import type { Demo3OnchainDexDiscoveryRequest } from "./bindings/Demo3OnchainDexDiscoveryRequest.ts";
|
||
import type { Demo3OnchainDexDiscoveryResult } from "./bindings/Demo3OnchainDexDiscoveryResult.ts";
|
||
import type { Demo3OnchainDexPairCandidate } from "./bindings/Demo3OnchainDexPairCandidate.ts";
|
||
import type { Demo3LocalDexCorpusSearchResult } from "./bindings/Demo3LocalDexCorpusSearchResult.ts";
|
||
import type { Demo3OnchainDexDiscoveryPayload } from "./bindings/Demo3OnchainDexDiscoveryPayload.ts";
|
||
|
||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||
|
||
|
||
interface Demo3Preset {
|
||
label: string;
|
||
dexCode: string;
|
||
programId: string;
|
||
description: string;
|
||
}
|
||
|
||
const presets: Demo3Preset[] = [
|
||
{ label: "PumpSwap", dexCode: "pump_swap", programId: "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", description: "DEX effectif PumpSwap." },
|
||
{ label: "Raydium CPMM", dexCode: "raydium_cpmm", programId: "CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C", description: "Raydium CPMM." },
|
||
{ label: "Raydium CLMM", dexCode: "raydium_clmm", programId: "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK", description: "Raydium CLMM." },
|
||
{ label: "Raydium AMM v4", dexCode: "raydium_amm_v4", programId: "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", description: "Raydium AMM v4 legacy. À prouver par corpus avant décodage swap." },
|
||
{ label: "Raydium Stable Swap", dexCode: "raydium_stable_swap", programId: "5quBtoiQqxF9Jv6KYKctB59NT3gtJD2Y65kdnB1Uev3h", description: "Stable Swap Raydium à vérifier par corpus." },
|
||
{ label: "Meteora DLMM", dexCode: "meteora_dlmm", programId: "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo", description: "Meteora DLMM." },
|
||
{ label: "Meteora DAMM v1", dexCode: "meteora_damm_v1", programId: "Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB", description: "Meteora DAMM v1." },
|
||
{ label: "Meteora DAMM v2", dexCode: "meteora_damm_v2", programId: "cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG", description: "Meteora DAMM v2." },
|
||
{ label: "Meteora DBC", dexCode: "meteora_dbc", programId: "dbcij3LWUppWqq96dh6gJWwBifmcGfLSB5D4DuSMaqN", description: "Meteora DBC." },
|
||
{ label: "Orca Whirlpools", dexCode: "orca_whirlpools", programId: "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc", description: "Orca Whirlpools CLMM." },
|
||
{ label: "FluxBeam", dexCode: "fluxbeam", programId: "FLUXubRmkEi2q6K3Y9kBPg9248ggaZVsoSFhtJHSrm1X", description: "FluxBeam." },
|
||
{ label: "DexLab", dexCode: "dexlab", programId: "DSwpgjMvXhtGn6BsbqmacdBZyfLj6jSWf3HJpdJtmg6N", description: "DexLab Swap/Pool." },
|
||
{ label: "metaDAO", dexCode: "metadao", programId: "", description: "DEX à vérifier. Aucun program id n'est inventé." },
|
||
{ label: "Printr", dexCode: "printr", programId: "", description: "DEX à vérifier. Aucun program id n'est inventé." },
|
||
];
|
||
|
||
let lastResultJson = "";
|
||
|
||
function byId<T extends HTMLElement>(id: string): T {
|
||
const element = document.getElementById(id);
|
||
if (element === null) {
|
||
throw new Error(`missing element #${id}`);
|
||
}
|
||
return element as T;
|
||
}
|
||
|
||
function valueOrNull(value: string): string | null {
|
||
const trimmed = value.trim();
|
||
return trimmed === "" ? null : trimmed;
|
||
}
|
||
|
||
function numberValueOrNull(value: string): number | null {
|
||
const trimmed = value.trim();
|
||
if (trimmed === "") {
|
||
return null;
|
||
}
|
||
const parsed = Number.parseInt(trimmed, 10);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function intValue(id: string, fallback: number): number {
|
||
const parsed = Number.parseInt(byId<HTMLInputElement>(id).value, 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function shortText(value: string | null, maxLength: number): string {
|
||
if (value === null) {
|
||
return "-";
|
||
}
|
||
if (value.length <= maxLength) {
|
||
return value;
|
||
}
|
||
return `${value.slice(0, maxLength)}…`;
|
||
}
|
||
|
||
function shortList(values: string[], maxItems: number, itemLength: number): string {
|
||
if (values.length === 0) {
|
||
return "-";
|
||
}
|
||
return values.slice(0, maxItems).map((value) => shortText(value, itemLength)).join(", ");
|
||
}
|
||
|
||
function candidateAccountList(accounts: Demo3OnchainDexPairCandidate["candidatePoolAccounts"], maxItems: number): string {
|
||
if (accounts.length === 0) {
|
||
return "-";
|
||
}
|
||
return accounts.slice(0, maxItems).map((account) => shortText(account.address, 14)).join(", ");
|
||
}
|
||
|
||
function tokenDeltaList(candidate: Demo3OnchainDexPairCandidate): string {
|
||
if (candidate.tokenBalanceDeltas.length === 0) {
|
||
return "-";
|
||
}
|
||
return candidate.tokenBalanceDeltas.slice(0, 4).map((delta) => {
|
||
const amount = delta.deltaRaw ?? "?";
|
||
return `${shortText(delta.mint, 10)}:${amount}`;
|
||
}).join(", ");
|
||
}
|
||
|
||
function appendLogLine(line: string): void {
|
||
const textarea = byId<HTMLTextAreaElement>("demo3LogTextarea");
|
||
const timestamp = new Date().toLocaleTimeString("fr-CH", { hour12: false });
|
||
const lines = textarea.value === "" ? [] : textarea.value.split("\n");
|
||
lines.push(`[${timestamp}] ${line}`);
|
||
textarea.value = lines.slice(-400).join("\n");
|
||
textarea.scrollTop = textarea.scrollHeight;
|
||
}
|
||
|
||
function setStatus(label: string, cssClass: string): void {
|
||
const badge = byId<HTMLElement>("demo3StatusBadge");
|
||
badge.className = `badge ${cssClass}`;
|
||
badge.textContent = label;
|
||
}
|
||
|
||
function populatePresetSelect(): void {
|
||
const select = byId<HTMLSelectElement>("demo3PresetSelect");
|
||
select.innerHTML = '<option value="">Custom / empty</option>';
|
||
presets.forEach((preset, index) => {
|
||
const option = document.createElement("option");
|
||
option.value = String(index);
|
||
option.textContent = preset.label;
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function applyPreset(indexText: string): void {
|
||
if (indexText === "") {
|
||
return;
|
||
}
|
||
const index = Number.parseInt(indexText, 10);
|
||
if (!Number.isFinite(index) || index < 0 || index >= presets.length) {
|
||
return;
|
||
}
|
||
const preset = presets[index];
|
||
byId<HTMLInputElement>("demo3DexCodeInput").value = preset.dexCode;
|
||
byId<HTMLInputElement>("demo3ProgramIdInput").value = preset.programId;
|
||
byId<HTMLElement>("demo3PresetHelp").textContent = preset.description;
|
||
}
|
||
|
||
function readOnchainRequest(): Demo3OnchainDexDiscoveryRequest {
|
||
return {
|
||
dexCode: valueOrNull(byId<HTMLInputElement>("demo3DexCodeInput").value),
|
||
programId: valueOrNull(byId<HTMLInputElement>("demo3ProgramIdInput").value),
|
||
httpRole: byId<HTMLInputElement>("demo3HttpRoleInput").value.trim() || "history_backfill",
|
||
signatureLimit: intValue("demo3SignatureLimitInput", 50),
|
||
transactionLimit: intValue("demo3TransactionLimitInput", 25),
|
||
candidateLimit: intValue("demo3CandidateLimitInput", 25),
|
||
};
|
||
}
|
||
|
||
function readLocalRequest(): Demo3LocalDexCorpusSearchRequest {
|
||
return {
|
||
dexCode: valueOrNull(byId<HTMLInputElement>("demo3DexCodeInput").value),
|
||
programId: valueOrNull(byId<HTMLInputElement>("demo3ProgramIdInput").value),
|
||
pairId: numberValueOrNull(byId<HTMLInputElement>("demo3PairIdInput").value),
|
||
poolAddress: valueOrNull(byId<HTMLInputElement>("demo3PoolAddressInput").value),
|
||
tokenMint: valueOrNull(byId<HTMLInputElement>("demo3TokenMintInput").value),
|
||
signature: valueOrNull(byId<HTMLInputElement>("demo3SignatureInput").value),
|
||
limit: intValue("demo3CandidateLimitInput", 25),
|
||
};
|
||
}
|
||
|
||
function clearFilters(): void {
|
||
byId<HTMLInputElement>("demo3DexCodeInput").value = "";
|
||
byId<HTMLInputElement>("demo3ProgramIdInput").value = "";
|
||
byId<HTMLInputElement>("demo3PairIdInput").value = "";
|
||
byId<HTMLInputElement>("demo3PoolAddressInput").value = "";
|
||
byId<HTMLInputElement>("demo3TokenMintInput").value = "";
|
||
byId<HTMLInputElement>("demo3SignatureInput").value = "";
|
||
byId<HTMLSelectElement>("demo3PresetSelect").value = "";
|
||
byId<HTMLElement>("demo3PresetHelp").textContent = "Choisis un DEX ou saisis un program id manuellement.";
|
||
}
|
||
|
||
function renderOnchainResult(result: Demo3OnchainDexDiscoveryResult): void {
|
||
byId<HTMLElement>("demo3SummarySignatureCount").textContent = String(result.fetchedSignatureCount);
|
||
byId<HTMLElement>("demo3SummaryFetchedTxCount").textContent = String(result.fetchedTransactionCount);
|
||
byId<HTMLElement>("demo3SummaryMissingTxCount").textContent = String(result.missingTransactionCount);
|
||
byId<HTMLElement>("demo3SummaryFailedTxCount").textContent = String(result.failedTransactionCount);
|
||
byId<HTMLElement>("demo3SummaryCandidateCount").textContent = String(result.candidateCount);
|
||
byId<HTMLElement>("demo3TargetText").textContent = `${result.resolvedDexCode ?? "custom"} / ${result.resolvedProgramId}`;
|
||
renderOnchainCandidates(result.candidates);
|
||
}
|
||
|
||
function renderOnchainCandidates(candidates: Demo3OnchainDexPairCandidate[]): void {
|
||
const body = byId<HTMLTableSectionElement>("demo3OnchainCandidateTableBody");
|
||
if (candidates.length === 0) {
|
||
body.innerHTML = '<tr><td colspan="11" class="text-body-secondary">No on-chain candidate.</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = candidates.map((candidate) => {
|
||
const verifiedPool = candidate.verifiedPoolAddress;
|
||
const firstCandidatePool = candidate.candidatePoolAccounts.length > 0 ? candidate.candidatePoolAccounts[0].address : null;
|
||
const poolForFilter = verifiedPool ?? candidate.poolAddress ?? firstCandidatePool;
|
||
if (poolForFilter !== null) {
|
||
byId<HTMLInputElement>("demo3PoolAddressInput").value = poolForFilter;
|
||
}
|
||
if (candidate.tokenAMint !== null && byId<HTMLInputElement>("demo3TokenMintInput").value.trim() === "") {
|
||
byId<HTMLInputElement>("demo3TokenMintInput").value = candidate.tokenAMint;
|
||
}
|
||
if (candidate.tokenAMint === null && candidate.observedTokenMints.length > 0 && byId<HTMLInputElement>("demo3TokenMintInput").value.trim() === "") {
|
||
byId<HTMLInputElement>("demo3TokenMintInput").value = candidate.observedTokenMints[0];
|
||
}
|
||
byId<HTMLInputElement>("demo3SignatureInput").value = candidate.signature;
|
||
const accountTitle = candidate.candidatePoolAccounts.map((account) => `${account.address} (${account.reason})`).join("\n");
|
||
const vaultTitle = candidate.candidateTokenVaultAccounts.map((account) => `${account.address} (${account.reason})`).join("\n");
|
||
return `
|
||
<tr>
|
||
<td class="font-monospace" title="${escapeHtml(candidate.signature)}">${escapeHtml(shortText(candidate.signature, 18))}</td>
|
||
<td>${candidate.slot ?? "-"}</td>
|
||
<td><span class="badge text-bg-info">${escapeHtml(candidate.candidateKind)}</span></td>
|
||
<td><span class="badge text-bg-${candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "secondary"}">${escapeHtml(candidate.confidence)}</span></td>
|
||
<td class="font-monospace" title="${escapeHtml(verifiedPool ?? "")}">${escapeHtml(shortText(verifiedPool, 14))}</td>
|
||
<td class="font-monospace" title="${escapeHtml(candidate.tokenAMint ?? "")}">${escapeHtml(shortText(candidate.tokenAMint, 14))}</td>
|
||
<td class="font-monospace" title="${escapeHtml(candidate.tokenBMint ?? "")}">${escapeHtml(shortText(candidate.tokenBMint, 14))}</td>
|
||
<td class="font-monospace" title="${escapeHtml(candidate.observedTokenMints.join("\n"))}">${escapeHtml(shortList(candidate.observedTokenMints, 3, 10))}</td>
|
||
<td class="font-monospace" title="${escapeHtml(tokenDeltaList(candidate))}">${escapeHtml(tokenDeltaList(candidate))}</td>
|
||
<td class="font-monospace" title="${escapeHtml(accountTitle)}">${escapeHtml(candidateAccountList(candidate.candidatePoolAccounts, 3))}<br /><span class="text-body-secondary">vaults: ${escapeHtml(candidateAccountList(candidate.candidateTokenVaultAccounts, 2))}</span></td>
|
||
<td class="small" title="${escapeHtml(vaultTitle)}">${escapeHtml(candidate.backfillHint)}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderLocalResult(result: Demo3LocalDexCorpusSearchResult): void {
|
||
byId<HTMLElement>("demo3SummaryLocalPairCount").textContent = String(result.summary.pairCount);
|
||
const body = byId<HTMLTableSectionElement>("demo3LocalPoolPairTableBody");
|
||
if (result.poolPairSamples.length === 0) {
|
||
body.innerHTML = '<tr><td colspan="8" class="text-body-secondary">No local pool/pair sample.</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = result.poolPairSamples.map((sample) => `
|
||
<tr>
|
||
<td>${escapeHtml(sample.dexCode ?? "-")}</td>
|
||
<td class="font-monospace" title="${escapeHtml(sample.poolAddress ?? "")}">${escapeHtml(shortText(sample.poolAddress, 16))}</td>
|
||
<td>${sample.pairId ?? "-"}</td>
|
||
<td>${escapeHtml(sample.pairSymbol ?? "-")}</td>
|
||
<td class="font-monospace" title="${escapeHtml(sample.baseMint ?? "")}">${escapeHtml(sample.baseSymbol ?? shortText(sample.baseMint, 12))}</td>
|
||
<td class="font-monospace" title="${escapeHtml(sample.quoteMint ?? "")}">${escapeHtml(sample.quoteSymbol ?? shortText(sample.quoteMint, 12))}</td>
|
||
<td>${sample.tradeEventCount}</td>
|
||
<td>${sample.pairCandleCount}</td>
|
||
</tr>`).join("");
|
||
}
|
||
|
||
async function discoverOnchain(): Promise<void> {
|
||
const request = readOnchainRequest();
|
||
setStatus("running", "text-bg-warning");
|
||
appendLogLine(`on-chain discovery dex='${request.dexCode ?? ""}' program='${request.programId ?? ""}' role='${request.httpRole}'`);
|
||
try {
|
||
const payload = await invoke<Demo3OnchainDexDiscoveryPayload>("demo3_discover_onchain_dex_pairs", { request });
|
||
lastResultJson = payload.resultJson;
|
||
byId<HTMLTextAreaElement>("demo3JsonTextarea").value = payload.resultJson;
|
||
renderOnchainResult(payload.result);
|
||
setStatus("ok", "text-bg-success");
|
||
appendLogLine(`on-chain discovery completed: candidates='${payload.result.candidateCount}' signatures='${payload.result.fetchedSignatureCount}'`);
|
||
} catch (error) {
|
||
setStatus("error", "text-bg-danger");
|
||
appendLogLine(`on-chain discovery failed: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
async function searchLocalDb(): Promise<void> {
|
||
const request = readLocalRequest();
|
||
setStatus("running", "text-bg-warning");
|
||
appendLogLine("local DB search started");
|
||
try {
|
||
const payload = await invoke<Demo3LocalDexCorpusSearchPayload>("demo3_search_local_dex_corpus", { request });
|
||
lastResultJson = payload.resultJson;
|
||
byId<HTMLTextAreaElement>("demo3JsonTextarea").value = payload.resultJson;
|
||
renderLocalResult(payload.result);
|
||
setStatus("ok", "text-bg-success");
|
||
appendLogLine(`local DB search completed: pairs='${payload.result.summary.pairCount}' tx='${payload.result.summary.transactionCount}'`);
|
||
} catch (error) {
|
||
setStatus("error", "text-bg-danger");
|
||
appendLogLine(`local DB search failed: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
async function copyJson(): Promise<void> {
|
||
if (lastResultJson === "") {
|
||
appendLogLine("nothing to copy");
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(lastResultJson);
|
||
appendLogLine("JSON copied to clipboard");
|
||
} catch (error) {
|
||
appendLogLine(`copy failed: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
function init(): void {
|
||
takeoverConsole();
|
||
void debug("demo3 on-chain discovery initialized");
|
||
debug("demo3 window loaded");
|
||
|
||
const sidebarToggle = document.querySelector<HTMLButtonElement>('#sidebarToggle');
|
||
if (sidebarToggle) {
|
||
// restaurer l’état depuis localStorage
|
||
if (localStorage.getItem('sidebar-toggle') === 'true') {
|
||
document.body.classList.add('sidenav-toggled');
|
||
}
|
||
|
||
sidebarToggle.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
document.body.classList.toggle('sidenav-toggled');
|
||
localStorage.setItem('sidebar-toggle', document.body.classList.contains('sidenav-toggled') ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||
Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||
const toastElList = document.querySelectorAll('.toast');
|
||
Array.from(toastElList).map(toastEl => new bootstrap.Toast(toastEl));
|
||
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
|
||
Array.from(popoverTriggerList).map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl));
|
||
|
||
const gobackto = location.pathname + location.search;
|
||
|
||
document.querySelectorAll<HTMLAnchorElement>('a[data-setlang]').forEach((a) => {
|
||
const href = a.getAttribute("href");
|
||
if (!href) return; // pas de href => on ignore
|
||
|
||
const url = new URL(href, location.origin);
|
||
url.searchParams.set("gobackto", gobackto);
|
||
|
||
// conserve une URL relative (path + query)
|
||
a.setAttribute("href", url.pathname + "?" + url.searchParams.toString());
|
||
});
|
||
|
||
populatePresetSelect();
|
||
byId<HTMLSelectElement>("demo3PresetSelect").addEventListener("change", (event) => {
|
||
applyPreset((event.target as HTMLSelectElement).value);
|
||
});
|
||
byId<HTMLButtonElement>("demo3DiscoverButton").addEventListener("click", () => {
|
||
void discoverOnchain();
|
||
});
|
||
byId<HTMLButtonElement>("demo3LocalSearchButton").addEventListener("click", () => {
|
||
void searchLocalDb();
|
||
});
|
||
byId<HTMLButtonElement>("demo3ClearFiltersButton").addEventListener("click", clearFilters);
|
||
byId<HTMLButtonElement>("demo3CopyJsonButton").addEventListener("click", () => {
|
||
void copyJson();
|
||
});
|
||
byId<HTMLButtonElement>("demo3ClearLogButton").addEventListener("click", () => {
|
||
byId<HTMLTextAreaElement>("demo3LogTextarea").value = "";
|
||
});
|
||
appendLogLine("ready");
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|