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
30 changes: 30 additions & 0 deletions lib/contracts/abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,36 @@ export const donateFunction = {
outputs: [],
} as const;

// ---------------------------------------------------------------------------
// MCV2_Bond events (from MCV2_Bond.sol upstream)
// ---------------------------------------------------------------------------

export const mcv2MintedEvent = {
type: "event",
name: "Minted",
inputs: [
{ name: "token", type: "address", indexed: true },
{ name: "account", type: "address", indexed: true },
{ name: "tokenAmount", type: "uint256", indexed: false },
{ name: "reserveAmount", type: "uint256", indexed: false },
{ name: "beneficiary", type: "address", indexed: false },
],
} as const;

export const mcv2BurnedEvent = {
type: "event",
name: "Burned",
inputs: [
{ name: "token", type: "address", indexed: true },
{ name: "account", type: "address", indexed: true },
{ name: "tokenAmount", type: "uint256", indexed: false },
{ name: "refundAmount", type: "uint256", indexed: false },
{ name: "beneficiary", type: "address", indexed: false },
],
} as const;

export const mcv2BondEventAbi = [mcv2MintedEvent, mcv2BurnedEvent] as const;

// ---------------------------------------------------------------------------
// MCV2_Bond view functions
// ---------------------------------------------------------------------------
Expand Down
45 changes: 45 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,51 @@ export interface Database {
};
Relationships: [];
};
trade_history: {
Row: {
id: number;
token_address: string;
storyline_id: number;
event_type: string;
price_per_token: number;
total_supply: number;
reserve_amount: number;
block_number: number;
block_timestamp: string;
tx_hash: string;
log_index: number;
contract_address: string;
};
Insert: {
id?: never;
token_address: string;
storyline_id: number;
event_type: string;
price_per_token: number;
total_supply: number;
reserve_amount: number;
block_number: number;
block_timestamp: string;
tx_hash: string;
log_index: number;
contract_address: string;
};
Update: {
id?: never;
token_address?: string;
storyline_id?: number;
event_type?: string;
price_per_token?: number;
total_supply?: number;
reserve_amount?: number;
block_number?: number;
block_timestamp?: string;
tx_hash?: string;
log_index?: number;
contract_address?: string;
};
Relationships: [];
};
};
Views: {
[_ in never]: never;
Expand Down
225 changes: 225 additions & 0 deletions src/app/api/cron/trade-history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { NextResponse } from "next/server";
import { decodeEventLog, formatUnits, type Log } from "viem";
import { publicClient } from "../../../../../lib/rpc";
import { createServerClient } from "../../../../../lib/supabase";
import { mcv2BondEventAbi } from "../../../../../lib/contracts/abi";
import { MCV2_BOND } from "../../../../../lib/contracts/constants";
import { erc20Abi } from "../../../../../lib/price";
import type { Database } from "../../../../../lib/supabase";

const SCAN_BLOCKS = BigInt(200);
const CURSOR_ID = 2; // separate cursor row from backfill (id=1)

type TradeInsert = Database["public"]["Tables"]["trade_history"]["Insert"];
type SupabaseClient = NonNullable<ReturnType<typeof createServerClient>>;

/** Fail closed in production when CRON_SECRET is unset */
function verifyCron(req: Request): boolean {
const secret = process.env.CRON_SECRET;
if (!secret) {
return process.env.NODE_ENV !== "production";
}
const authHeader = req.headers.get("authorization");
return authHeader === `Bearer ${secret}`;
}

export async function GET(req: Request) {
if (!verifyCron(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

const currentBlock = await publicClient.getBlockNumber();

// Read cursor
const { data: cursor } = await supabase
.from("backfill_cursor")
.select("last_block")
.eq("id", CURSOR_ID)
.single();

// If no cursor row exists, create one
if (!cursor) {
await supabase.from("backfill_cursor").insert({ id: CURSOR_ID, last_block: 0 });
}

const lastBlock = cursor?.last_block ? BigInt(cursor.last_block) : BigInt(0);
const fromBlock = lastBlock > BigInt(0) ? lastBlock + BigInt(1) : BigInt(0);

if (fromBlock > currentBlock) {
return NextResponse.json({ skipped: true, reason: "Already up to date" });
}

const toBlock =
fromBlock + SCAN_BLOCKS < currentBlock ? fromBlock + SCAN_BLOCKS : currentBlock;

// Load known storyline token addresses
const { data: storylines } = await supabase
.from("storylines")
.select("storyline_id, token_address")
.not("token_address", "is", null);

const tokenToStoryline = new Map<string, number>();
for (const s of storylines ?? []) {
if (s.token_address) {
tokenToStoryline.set(s.token_address.toLowerCase(), s.storyline_id);
}
}

if (tokenToStoryline.size === 0) {
// Advance cursor even if no tokens to track
await supabase
.from("backfill_cursor")
.update({ last_block: Number(toBlock), updated_at: new Date().toISOString() })
.eq("id", CURSOR_ID);
return NextResponse.json({ skipped: true, reason: "No storyline tokens to track" });
}

// Fetch all MCV2_Bond logs in range
const logs = await publicClient.getLogs({
address: MCV2_BOND,
fromBlock,
toBlock,
});

let inserted = 0;
let skipped = 0;
let errors = 0;

const blockTimestampCache = new Map<bigint, string>();
async function getTimestamp(blockNumber: bigint): Promise<string> {
const cached = blockTimestampCache.get(blockNumber);
if (cached) return cached;
const block = await publicClient.getBlock({ blockNumber });
const ts = new Date(Number(block.timestamp) * 1000).toISOString();
blockTimestampCache.set(blockNumber, ts);
return ts;
}

for (const log of logs) {
try {
const decoded = decodeEventLog({
abi: mcv2BondEventAbi,
data: log.data,
topics: log.topics,
});

if (decoded.eventName !== "Minted" && decoded.eventName !== "Burned") {
skipped++;
continue;
}

const tokenAddress = (
decoded.args as { token: `0x${string}` }
).token.toLowerCase();
const storylineId = tokenToStoryline.get(tokenAddress);
if (storylineId === undefined) {
skipped++;
continue;
}

await processTradeEvent(
decoded,
log,
tokenAddress,
storylineId,
supabase,
getTimestamp,
);
inserted++;
} catch (err) {
// Skip events that don't decode as Minted/Burned
if (err instanceof Error && err.message.includes("could not find")) {
skipped++;
continue;
}
console.error(
`Trade indexer error at tx=${log.transactionHash} logIndex=${log.logIndex}:`,
err instanceof Error ? err.message : err,
);
errors++;
}
}

// Advance cursor
await supabase
.from("backfill_cursor")
.update({ last_block: Number(toBlock), updated_at: new Date().toISOString() })
.eq("id", CURSOR_ID);

return NextResponse.json({
scanned: { fromBlock: Number(fromBlock), toBlock: Number(toBlock) },
trades: inserted,
skipped,
errors,
});
}

type DecodedEvent = ReturnType<typeof decodeEventLog<typeof mcv2BondEventAbi>>;

async function processTradeEvent(
decoded: DecodedEvent,
log: Log,
tokenAddress: string,
storylineId: number,
supabase: SupabaseClient,
getTimestamp: (blockNumber: bigint) => Promise<string>,
) {
const args = decoded.args as {
token: `0x${string}`;
account: `0x${string}`;
tokenAmount: bigint;
reserveAmount?: bigint;
refundAmount?: bigint;
};

const isMint = decoded.eventName === "Minted";
const reserveAmount = isMint ? args.reserveAmount! : args.refundAmount!;
const tokenAmount = args.tokenAmount;

// Compute price per token (reserve per token, 18 decimals)
const pricePerToken =
tokenAmount > BigInt(0)
? Number(formatUnits(reserveAmount, 18)) / Number(formatUnits(tokenAmount, 18))
: 0;

// Read total supply at this block
let totalSupply = BigInt(0);
try {
totalSupply = await publicClient.readContract({
address: args.token,
abi: erc20Abi,
functionName: "totalSupply",
blockNumber: log.blockNumber!,
});
} catch {
// Fall back to 0 if historical read fails
}

const timestampISO = await getTimestamp(log.blockNumber!);

const row: TradeInsert = {
token_address: tokenAddress,
storyline_id: storylineId,
event_type: isMint ? "mint" : "burn",
price_per_token: pricePerToken,
total_supply: Number(formatUnits(totalSupply, 18)),
reserve_amount: Number(formatUnits(reserveAmount, 18)),
block_number: Number(log.blockNumber!),
block_timestamp: timestampISO,
tx_hash: log.transactionHash!.toLowerCase(),
log_index: log.logIndex!,
contract_address: MCV2_BOND.toLowerCase(),
};

const { error } = await supabase
.from("trade_history")
.upsert(row, { onConflict: "tx_hash,log_index" });
if (error) {
throw new Error(`Database error (trade): ${error.message}`);
}
}
Loading
Loading