Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/api/src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { crawlScarceSales } from "#api/indexer/scarce-sales";
import { classifyVaults } from "#api/indexer/vault-contents";
import { crawlEmblemMeta } from "#api/indexer/emblem-meta";
import { crawlEmblemTransfers } from "#api/indexer/emblem-transfers";
import { crawlEmblemListings } from "#api/indexer/emblem-listings";
import { crawlTokenscanCollections } from "#api/indexer/tokenscan-collections";
import { syncCounterTags } from "#api/indexer/counters";
import { buildScamAttribution } from "#api/indexer/emblem-scam";
Expand Down Expand Up @@ -194,9 +193,10 @@ admin.post("/admin/crawl-emblem", async (c) => {
return c.json(await crawlEmblemStep(c.env));
});

// Refresh live Emblem listings from the Sequence Marketplace API (rotating contract subset per call). Loop
// this to sweep all ~36 contracts; the cron also advances it ~hourly.
admin.post("/admin/crawl-emblem-listings", async (c) => c.json(await crawlEmblemListings(c.env)));
// Retired explicitly rather than silently running the discontinued provider.
admin.post("/admin/crawl-emblem-listings", (c) =>
c.json({ error: "Live Ethereum listings are no longer supported" }, 410),
);

// Fold in the tokenscan project directory (~60 collections + sites) as source='tokenscan' collection tags.
admin.post("/admin/crawl-tokenscan", async (c) => c.json(await crawlTokenscanCollections(c.env)));
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export interface Env {
ADMIN_TOKEN: string;
ALCHEMY_KEY: string;
ETHERSCAN_KEY: string;
SEQUENCE_ACCESS_KEY: string;
/** Optional: sustains the CMC aggregate calendar via free-tier quotes/latest; job skips when unset. */
CMC_API_KEY?: string;
}
122 changes: 1 addition & 121 deletions apps/api/src/indexer/emblem-listings.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
/** Live asks for Ethereum NFTs that wrap Counterparty assets, sourced from Sequence Marketplace. */
import type { Env } from "#api/env";
import { fetchSequenceListingsPage, SequenceUnregisteredCollection } from "#api/integrations/sequence";
import { getCoreStateInt, getCoreStateStringArray, setCoreState } from "#api/indexer/core-state";

// 40 pages × 100 = a 4,000-listing ceiling per contract. Five pages proved real-world short: the
// curated-fakes contract (0x7e6027…, ~1,700 vaults) carries 500+ live OpenSea listings, and the cap
// throw stalled the whole rotation at its cursor for a day. A page is one subrequest; forty stays
// trivial inside the invocation budget.
const MAX_PAGES_PER_CONTRACT = 40;
const CONTRACTS_PER_RUN = 6;
/** Historical listing projection retained for recovery tooling; live ingestion is retired. */
const MAP_CHUNK = 90;

export interface Ask {
Expand All @@ -25,51 +15,6 @@ export interface ListingRow extends Ask {
assetId: number | null;
}

/** A contract sweep is accepted only when every provider page is present. */
async function sweepContract(key: string, contract: string): Promise<Ask[]> {
const asks: Ask[] = [];
for (let page = 1; page <= MAX_PAGES_PER_CONTRACT; page++) {
const response = await fetchSequenceListingsPage(key, contract, page);
for (const item of response.collectibles ?? []) {
const order = item.listing ?? item.order;
const tokenId = order?.tokenId ?? item.metadata?.tokenId;
if (!order || !tokenId || order.priceUSD == null) continue;
const expiry = order.validUntil ? Math.floor(new Date(order.validUntil).getTime() / 1000) : 0;
asks.push({
tokenId: String(tokenId),
orderId: order.orderId ?? null,
marketplace: String(order.marketplace ?? ""),
priceUsd: order.priceUSD,
priceAmount: order.priceAmount ?? null,
currency: (order.priceCurrencyAddress ?? "").toLowerCase(),
expiry: Number.isFinite(expiry) ? expiry : 0,
});
}
if (!response.page?.more) return asks;
}
throw new Error(`Sequence listing sweep exceeded ${MAX_PAGES_PER_CONTRACT} pages for ${contract}`);
}

async function resolveAssets(db: D1Database, contract: string, asks: Ask[]): Promise<ListingRow[]> {
const assetByToken = new Map<string, number>();
const tokenIds = [...new Set(asks.map((ask) => ask.tokenId))];
for (let index = 0; index < tokenIds.length; index += MAP_CHUNK) {
const chunk = tokenIds.slice(index, index + MAP_CHUNK);
const rows = await db
.prepare(
`SELECT vault.token_id,vault.contents_asset_id
FROM emblem_vaults vault
JOIN address_dictionary contract ON contract.address_id=vault.contract_id
WHERE contract.address=? AND vault.token_id IN (${chunk.map(() => "?").join(",")})`,
)
.bind(contract, ...chunk)
.all<{ token_id: string; contents_asset_id: number | null }>();
for (const row of rows.results)
if (row.contents_asset_id != null) assetByToken.set(row.token_id, row.contents_asset_id);
}
return asks.map((ask) => ({ ...ask, assetId: assetByToken.get(ask.tokenId) ?? null }));
}

/** Reconcile one fully observed contract inside an unpublished generation. */
export async function upsertEmblemListingContract(
db: D1Database,
Expand Down Expand Up @@ -129,68 +74,3 @@ export async function upsertEmblemListingContract(
.bind(generation, contract, observedAt)
.run();
}

/** Advance a complete generation in bounded contract groups; publish only after every contract succeeds. */
export async function crawlEmblemListings(env: Env): Promise<Record<string, unknown>> {
if (!env.SEQUENCE_ACCESS_KEY) return { skipped: "no SEQUENCE_ACCESS_KEY" };
const contracts = await getCoreStateStringArray(env.CORE_DB, "emblem_contracts");
if (contracts.length === 0) return { skipped: "no contracts" };

const published = await getCoreStateInt(env.CORE_DB, "emblem_listings_generation");
const generation = published + 1;
let cursor = await getCoreStateInt(env.CORE_DB, "emblem_listings_contract_cursor");
if (cursor < 0 || cursor >= contracts.length) cursor = 0;
let live = 0;
let upserts = 0;
let unregistered = 0;
const processed: string[] = [];

for (let count = 0; count < CONTRACTS_PER_RUN && cursor < contracts.length; count++) {
const contract = contracts[cursor].toLowerCase();
let asks: Ask[];
try {
asks = await sweepContract(env.SEQUENCE_ACCESS_KEY, contract);
} catch (error) {
// A contract the Sequence project doesn't include is a complete, honestly-empty observation —
// most vault contracts are unregistered, and aborting on the first one wedged the venue at
// generation zero while the registered contracts carried real (OpenSea-sourced) listings.
// Anything else is a provider failure: abort so an incomplete generation never publishes.
if (error instanceof SequenceUnregisteredCollection) {
asks = [];
unregistered++;
} else {
return { generation, processed: processed.length, failed: contract, error: String(error).slice(0, 120) };
}
}
const observedAt = Date.now();
const rows = await resolveAssets(env.CORE_DB, contract, asks);
await upsertEmblemListingContract(env.CORE_DB, generation, contract, rows, observedAt);
cursor++;
await setCoreState(env.CORE_DB, "emblem_listings_contract_cursor", cursor);
live += rows.length;
upserts += rows.length;
processed.push(contract);
}

if (cursor >= contracts.length) {
await env.CORE_DB.batch([
env.CORE_DB.prepare(
`INSERT INTO core_state(key,value) VALUES('emblem_listings_generation',?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`,
).bind(String(generation)),
env.CORE_DB.prepare(
`INSERT INTO core_state(key,value) VALUES('emblem_listings_contract_cursor','0')
ON CONFLICT(key) DO UPDATE SET value=excluded.value`,
),
env.CORE_DB.prepare(`DELETE FROM emblem_listings WHERE generation<?`).bind(generation),
]);
}
return {
generation,
published: cursor >= contracts.length,
processed: processed.length,
unregistered,
live,
upserts,
};
}
119 changes: 0 additions & 119 deletions apps/api/src/integrations/sequence.ts

This file was deleted.

46 changes: 9 additions & 37 deletions apps/api/src/queries/radar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,8 @@ export function radarEstablished(db: D1Database, limit = 40): Promise<RadarAsset
);
}

/** Available now: high-Conviction assets (raw ≥ calibrated p90) purchasable right now, ranked by Conviction,
* showing the CHEAPEST path per asset (in USD) across two venues:
* - dispenser: cheapest open Counterparty dispenser holding stock (fixed-price BTC vending, instant buy)
* - emblem: cheapest live Ethereum listing of a vault wrapping the card (Sequence-aggregated ask, USD)
* The dispenser ask converts to USD at the latest daily BTC rate so the two venues are comparable. */
/** Available now: eligible high-Conviction assets with a stocked Counterparty dispenser.
* Ethereum asks are no longer advertised: their Sequence provider is retiring. */
export function radarAvailable(db: D1Database, limit = 40): Promise<AvailableAsset[]> {
return q<AvailableAsset>(
db,
Expand All @@ -68,51 +65,26 @@ export function radarAvailable(db: D1Database, limit = 40): Promise<AvailableAss
AND (dispenser.oracle_address_id IS NULL OR quote.price > 0)
GROUP BY dispenser.asset_id
),
emb_ranked AS ( -- cheapest current-generation listing, keeping venue and URL from the same order
SELECT asset_id,price_usd ask_usd,marketplace,url,
ROW_NUMBER() OVER (PARTITION BY asset_id ORDER BY price_usd,order_id) rank
FROM emblem_listings
WHERE generation=COALESCE((SELECT CAST(value AS INTEGER) FROM core_state
WHERE key='emblem_listings_generation'),0)
AND asset_id IS NOT NULL AND price_usd IS NOT NULL
AND (expiry=0 OR expiry>=unixepoch())
),
emb AS (
SELECT asset_id,ask_usd,marketplace,url FROM emb_ranked WHERE rank=1
),
offers AS MATERIALIZED (
SELECT asset_id FROM disp
UNION
SELECT asset_id FROM emb
),
conv AS (
SELECT signal.asset_id,dictionary.asset, (${CONVICTION}) conviction,
COALESCE(signal.max_realized_usd,0) market_usd,
signal.holders,signal.supply,signal.avg_holder_dex,signal.pct_creator_holders
FROM offers
JOIN asset_signals signal ON signal.asset_id=offers.asset_id
FROM disp
JOIN asset_signals signal ON signal.asset_id=disp.asset_id
JOIN asset_dictionary dictionary ON dictionary.asset_id=signal.asset_id
WHERE ${ELIGIBLE} AND (${CONVICTION}) >= ${CONVICTION_PCT.p90}
)
SELECT c.asset,state.asset_longname,
ROUND(c.conviction,2) conviction, ROUND(c.market_usd) market_usd,
c.holders, CAST(ROUND(c.supply) AS INTEGER) supply,
ROUND(c.avg_holder_dex,1) holder_dex, ROUND(c.pct_creator_holders) creator_pct,
CASE WHEN emb.ask_usd IS NOT NULL AND (disp.ask_btc IS NULL OR emb.ask_usd < disp.ask_btc*(SELECT usd FROM px))
THEN 'emblem' ELSE 'dispenser' END venue,
CASE WHEN emb.ask_usd IS NOT NULL AND (disp.ask_btc IS NULL OR emb.ask_usd < disp.ask_btc*(SELECT usd FROM px))
THEN ROUND(emb.ask_usd) ELSE ROUND(disp.ask_btc*(SELECT usd FROM px)) END ask_usd,
CASE WHEN emb.ask_usd IS NOT NULL AND (disp.ask_btc IS NULL OR emb.ask_usd < disp.ask_btc*(SELECT usd FROM px))
THEN NULL ELSE ROUND(disp.ask_btc,8) END ask_btc,
CASE WHEN emb.ask_usd IS NOT NULL AND (disp.ask_btc IS NULL OR emb.ask_usd < disp.ask_btc*(SELECT usd FROM px))
THEN emb.marketplace ELSE NULL END marketplace,
CASE WHEN emb.ask_usd IS NOT NULL AND (disp.ask_btc IS NULL OR emb.ask_usd < disp.ask_btc*(SELECT usd FROM px))
THEN emb.url ELSE NULL END listing_url
'dispenser' venue,
ROUND(disp.ask_btc*(SELECT usd FROM px)) ask_usd,
ROUND(disp.ask_btc,8) ask_btc,
NULL marketplace, NULL listing_url
FROM conv c
LEFT JOIN disp ON disp.asset_id=c.asset_id
LEFT JOIN emb ON emb.asset_id=c.asset_id
JOIN disp ON disp.asset_id=c.asset_id
LEFT JOIN assets state ON state.asset_id=c.asset_id
WHERE disp.asset_id IS NOT NULL OR emb.asset_id IS NOT NULL
ORDER BY c.conviction DESC,c.asset ASC LIMIT ${limit}`,
);
}
2 changes: 1 addition & 1 deletion apps/api/src/read/radar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ radar.get("/v2/radar/emergence", (c) =>
);

radar.get("/v2/radar", (c) =>
cached(c, "radar:established", { ttl: 600, edge: 120 }, async (): Promise<Envelope<RadarPayload>> => {
cached(c, "radar:established:dispenser-only", { ttl: 600, edge: 120 }, async (): Promise<Envelope<RadarPayload>> => {
const [establishedRows, availableRows] = await Promise.all([
radarEstablished(c.env.CORE_DB),
radarAvailable(c.env.CORE_DB),
Expand Down
5 changes: 0 additions & 5 deletions apps/api/src/scheduler/canonical-maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { crawlCollections } from "#api/indexer/collections";
import { runCoreAssetSignalsStep } from "#api/indexer/core-asset-signals";
import { buildCuratedCollections } from "#api/indexer/curated-collections";
import { reconcileRecentDailyTransactions } from "#api/indexer/daily-metrics";
import { crawlEmblemListings } from "#api/indexer/emblem-listings";
import { crawlEmblemMeta } from "#api/indexer/emblem-meta";
import { buildScamAttribution } from "#api/indexer/emblem-scam";
import { crawlEmblemTransfers } from "#api/indexer/emblem-transfers";
Expand Down Expand Up @@ -66,9 +65,6 @@ async function blockGatedProviderJob(
return result as Record<string, unknown>;
}

const maybeCrawlEmblemListings = (env: Env) =>
blockGatedProviderJob(env, "emblem_listings_synced_blk", 6, () => crawlEmblemListings(env));

const maybeRebuildTags = (env: Env) =>
runCoreBlockGated(env.CORE_DB, "tags_rebuilt_blk", 144, async () => {
await buildTags(env);
Expand Down Expand Up @@ -118,7 +114,6 @@ export async function runCanonicalMaintenance(env: Env): Promise<boolean> {
await runScheduledJob("crawlTokenscan", () => maybeCrawlTokenscan(env));
await runScheduledJob("crawlEmblemTransfers", () => crawlEmblemTransfers(env));
await runScheduledJob("backfillEthereumBlockTimes", () => backfillEthereumBlockTimes(env));
await runScheduledJob("crawlEmblemListings", () => maybeCrawlEmblemListings(env));
await runScheduledJob("crawlScarceSales", () => crawlScarceSales(env));
await runScheduledJob("classifyVaults", () => classifyVaults(env));
await runScheduledJob("crawlEmblemMeta", () => crawlEmblemMeta(env));
Expand Down
Loading
Loading