Skip to content
Open
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ jobs:
app:
name: app · lint, typecheck, unit tests, build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: quote_indexer
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
defaults:
run:
working-directory: app
Expand All @@ -30,6 +43,10 @@ jobs:
- run: npm run lint
- run: npx tsc --noEmit -p .
- run: npm test
- name: quote fee indexer integration
env:
QUOTE_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/quote_indexer
run: node --conditions=react-server --import tsx scripts/quote-indexer-check.mts
- name: production build
env:
NEXT_PUBLIC_SITE_URL: https://openlaunch.lol
Expand All @@ -53,5 +70,7 @@ jobs:
- run: forge build --sizes
- run: forge test --match-path test/LaunchFactory.t.sol -vv
- run: forge test --match-path test/LaunchFactory.arcGuard.t.sol -vv
- run: forge test --match-path test/QuoteFees.t.sol -vv
- run: forge test --match-path test/LaunchEvents.t.sol -vv
- run: script/check-arc-factory.sh
# live-fork suites (FORK_TESTS=true) need RPC access and run locally before a release; see README
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,7 @@ Found something? Open an issue or reach out on X: [@openlaunch_lol](https://x.co
## License

MIT — see [`LICENSE`](LICENSE).

## Quote-only creator fees

New launches can use the optional quote-only suite: zero LP fees plus an immutable hook charging the quote asset on buys and sells. Existing deployments keep their fee behavior. See [contracts and deployment guide](contracts/docs/QUOTE_FEES.md).
20 changes: 20 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,23 @@ IMAGE_STORE=local

# Wallets allowed to moderate posts (comma-separated, lowercase)
ADMIN_WALLETS=

# Quote-only creator fees: all addresses + deployment block required. Keep launch disabled until verified.
NEXT_PUBLIC_QUOTE_FACTORY=
NEXT_PUBLIC_QUOTE_LOCKER=
NEXT_PUBLIC_QUOTE_VAULT=
NEXT_PUBLIC_QUOTE_HOOK=
NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK=
NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED=false
NEXT_PUBLIC_QUOTE_FACTORY_ROBINHOOD=
NEXT_PUBLIC_QUOTE_LOCKER_ROBINHOOD=
NEXT_PUBLIC_QUOTE_VAULT_ROBINHOOD=
NEXT_PUBLIC_QUOTE_HOOK_ROBINHOOD=
NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK_ROBINHOOD=
NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED_ROBINHOOD=false
NEXT_PUBLIC_QUOTE_FACTORY_ARC=
NEXT_PUBLIC_QUOTE_LOCKER_ARC=
NEXT_PUBLIC_QUOTE_VAULT_ARC=
NEXT_PUBLIC_QUOTE_HOOK_ARC=
NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK_ARC=
NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED_ARC=false
28 changes: 28 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,34 @@ CREATE TABLE IF NOT EXISTS bb_quote_tokens (
PRIMARY KEY (chain_id, address)
);

-- Immutable deployment and pool identity. lp_fee remains the displayed trading rate.
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS suite_id text NOT NULL DEFAULT 'lp-v1';
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS fee_asset_mode text NOT NULL DEFAULT 'both';
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS factory_address text;
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS locker_address text;
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS fee_contract_address text;
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS hook_address text NOT NULL DEFAULT '0x0000000000000000000000000000000000000000';
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS pool_fee_pips integer;
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS creator_fee_pips integer;
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS tick_spacing integer NOT NULL DEFAULT 200;
ALTER TABLE bb_launches ADD COLUMN IF NOT EXISTS fees_quote_accrued numeric(78,0) NOT NULL DEFAULT 0;
UPDATE bb_launches SET pool_fee_pips = lp_fee WHERE pool_fee_pips IS NULL;
UPDATE bb_launches SET creator_fee_pips = lp_fee WHERE creator_fee_pips IS NULL;
-- Nullable during rolling deploys: the legacy indexer can still insert rows.

ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS quote_fee numeric(78,0) NOT NULL DEFAULT 0;
ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS trader_amount0 numeric(78,0);
ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS trader_amount1 numeric(78,0);
ALTER TABLE bb_launch_fee_events ADD COLUMN IF NOT EXISTS fee_contract_address text;
-- New suites need their own cursor when enabled after the legacy cursor has already passed their deployment.
CREATE TABLE IF NOT EXISTS bb_launch_suite_cursor (
chain_id integer NOT NULL,
suite_id text NOT NULL,
factory_address text NOT NULL,
cursor_block bigint NOT NULL DEFAULT 0,
PRIMARY KEY (chain_id, suite_id, factory_address)
);

-- ── token banners (2026-10-05) ─────────────────────────────────────────────
-- An optional wide image the creator uploads beside the logo. Same rules as image_url (https only; uploads are
-- re-encoded server-side, here to a 1500×500 WebP). NULL means no banner: the market cards draw one from the logo.
Expand Down
75 changes: 75 additions & 0 deletions app/scripts/quote-indexer-check.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/** Local-only integration check. Run against an empty disposable database with tsconfig.json (real DB module). */
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { encodeAbiParameters, encodeEventTopics, parseEventLogs, type Abi, type AbiEvent, type Address, type Hex, type Log } from "viem";
if (!process.env.QUOTE_TEST_DATABASE_URL || new URL(process.env.QUOTE_TEST_DATABASE_URL).hostname !== "127.0.0.1") throw new Error("Set QUOTE_TEST_DATABASE_URL to a disposable database on 127.0.0.1");
process.env.DATABASE_URL = process.env.QUOTE_TEST_DATABASE_URL;
const factory="0x000000000000000000000000000000000000a001", locker="0x000000000000000000000000000000000000a002", vault="0x000000000000000000000000000000000000a003", hook="0x00000000000000000000000000000000000020cc";
const legacyFactory="0x000000000000000000000000000000000000a101", legacyLocker="0x000000000000000000000000000000000000a102";
Object.assign(process.env,{ NEXT_PUBLIC_QUOTE_FACTORY:factory,NEXT_PUBLIC_QUOTE_LOCKER:locker,NEXT_PUBLIC_QUOTE_VAULT:vault,NEXT_PUBLIC_QUOTE_HOOK:hook,NEXT_PUBLIC_QUOTE_DEPLOY_BLOCK:"1",NEXT_PUBLIC_QUOTE_LAUNCH_ENABLED:"true",NEXT_PUBLIC_LAUNCH_FACTORY:legacyFactory,NEXT_PUBLIC_LAUNCH_LOCKER:legacyLocker,LAUNCH_DEPLOY_BLOCK:"1" });
const [{db},{publicClient},{QUOTE_FACTORY_ABI,QUOTE_HOOK_ABI,QUOTE_VAULT_ABI},{POOL_MANAGER_ABI,LAUNCH_FACTORY_ABI,LAUNCH_LOCKER_ABI},{applyLaunchTx,pollLaunches},{walletQuoteFees},{launchpad}] = await Promise.all([
import("../src/lib/db"),import("../src/lib/chain"),import("../src/lib/launchpad/quote-abi"),import("../src/lib/launchpad/abi"),import("../src/lib/launchpad/indexer"),import("../src/lib/launchpad/queries"),import("../src/lib/launchpad/config")]);
const sql=db();
const client=publicClient("base");
const token="0x000000000000000000000000000000000000ffff" as Address, quote="0x0000000000000000000000000000000000000000" as Address, wallet="0x000000000000000000000000000000000000b001" as Address;
const legacyToken="0x000000000000000000000000000000000000fff1" as Address;
const pool=`0x${"1".repeat(64)}` as Hex, hash=`0x${"2".repeat(64)}` as Hex;
function event(abi: Abi,name: string,address: Address,args: Record<string,unknown>,index: number): Log {
const e=abi.find((a)=>a.type==="event" && a.name===name) as AbiEvent;
const fields=e.inputs.filter((i)=>!i.indexed);
return { address,topics:encodeEventTopics({abi:[e],eventName:name,args}),data:encodeAbiParameters(fields,fields.map((i)=>args[i.name!])),blockNumber:2n,blockHash:hash,transactionHash:hash,transactionIndex:0,logIndex:index,removed:false } as Log;
}
let logs=[
event(QUOTE_FACTORY_ABI,"QuoteLaunched",factory,{token,tokenId:1n,launcher:wallet,quote,poolId:pool,startTick:184200,creatorFeePips:10000,supply:1000000000n*10n**18n,metadataURI:""},0),
event(POOL_MANAGER_ABI,"Swap",launchpad("base").v4.poolManager,{id:pool,sender:wallet,amount0:-99n,amount1:1000n,sqrtPriceX96:2n**96n,liquidity:1n,tick:1,fee:0},1),
event(QUOTE_HOOK_ABI,"QuoteSwap",hook,{poolId:pool,executor:wallet,zeroForOne:true,exactInput:true,quoteFee:1n,amount0:-100n,amount1:1000n},2),
event(QUOTE_VAULT_ABI,"Credited",vault,{tokenId:1n,account:wallet,currency:quote,amount:1n},4),
event(QUOTE_VAULT_ABI,"Collected",vault,{tokenId:1n,token,quoteAmount:1n,tokenAmount:0n},5),
];
client.getTransactionReceipt=async()=>({status:"success",logs}) as Awaited<ReturnType<typeof client.getTransactionReceipt>>;
client.getBlock=async()=>({timestamp:123n}) as Awaited<ReturnType<typeof client.getBlock>>;
client.getTransaction=async()=>({from:wallet}) as Awaited<ReturnType<typeof client.getTransaction>>;
client.readContract=(async (p: {functionName: string})=> p.functionName==="name"?"Quote":p.functionName==="symbol"?"QT":[{payout:wallet,bps:10000}]) as typeof client.readContract;
client.getBlockNumber=async()=>4n;
client.getLogs=(async (p: {address: string | string[];fromBlock: bigint;toBlock: bigint})=>parseEventLogs({abi:[...QUOTE_FACTORY_ABI,...QUOTE_HOOK_ABI,...QUOTE_VAULT_ABI,...POOL_MANAGER_ABI,...LAUNCH_FACTORY_ABI,...LAUNCH_LOCKER_ABI],logs:logs.filter((l)=>(Array.isArray(p.address)?p.address:[p.address]).some((a)=>l.address.toLowerCase()===a.toLowerCase()) && l.blockNumber!>=p.fromBlock && l.blockNumber!<=p.toBlock)})) as typeof client.getLogs;
try {
const schema=await readFile(new URL("../db/schema.sql",import.meta.url),"utf8");
await sql.begin((tx)=>tx.unsafe(schema)); await sql.begin((tx)=>tx.unsafe(schema));
assert.equal((await sql`SELECT count(*) AS n FROM bb_launches`)[0].n,0n,"use an empty test database");
const first=await applyLaunchTx("base",hash); assert.deepEqual([first.launches,first.swaps,first.fees],[1,1,2]);
const retry=await applyLaunchTx("base",hash); assert.deepEqual([retry.launches,retry.swaps,retry.fees],[0,0,0]);
const [launch]=await sql`SELECT * FROM bb_launches WHERE token=${token}`;
assert.equal(launch.suite_id,"quote-v2"); assert.equal(launch.fee_contract_address,vault); assert.equal(launch.hook_address,hook); assert.equal(launch.pool_fee_pips,0); assert.equal(launch.lp_fee,10000);
assert.equal(launch.volume_quote,"100"); assert.equal(launch.fees_quote_accrued,"1"); assert.equal(launch.fees_quote_collected,"1");
const [swap]=await sql`SELECT * FROM bb_launch_swaps`; assert.equal(swap.amount0,"-99"); assert.equal(swap.trader_amount0,"-100"); assert.equal(swap.quote_fee,"1");
assert.equal((await walletQuoteFees(wallet)).get(`8453:${token}`),undefined,"credits have not been paid");
// Both suites can own the same NFT ID; only the emitting contract identifies its launch.
logs = [
event(LAUNCH_FACTORY_ABI,"Launched",legacyFactory,{token:legacyToken,tokenId:1n,launcher:wallet,quote,poolId:`0x${"3".repeat(64)}`,startTick:184200,lpFee:10000,supply:1000000000n*10n**18n,metadataURI:""},7),
event(LAUNCH_LOCKER_ABI,"Collected",legacyLocker,{tokenId:1n,token:legacyToken,quoteAmount:7n,tokenAmount:0n},8),
event(LAUNCH_LOCKER_ABI,"Paid",legacyLocker,{tokenId:1n,account:wallet,currency:quote,amount:7n},9),
event(LAUNCH_LOCKER_ABI,"Credited",legacyLocker,{account:wallet,currency:quote,amount:2n},10),
event(LAUNCH_LOCKER_ABI,"Claimed",legacyLocker,{account:wallet,currency:quote,amount:2n},11),
];
const legacy=await applyLaunchTx("base",hash);
assert.deepEqual([legacy.launches,legacy.swaps,legacy.fees],[1,0,4]);
assert.equal((await sql`SELECT fees_quote_collected FROM bb_launches WHERE token=${legacyToken}`)[0].fees_quote_collected,"7");
assert.equal((await sql`SELECT fees_quote_collected FROM bb_launches WHERE token=${token}`)[0].fees_quote_collected,"1");
assert.equal((await walletQuoteFees(wallet)).get(`8453:${token}`),undefined);
assert.equal((await sql`SELECT token FROM bb_launch_fee_events WHERE kind='paid' AND fee_contract_address=${legacyLocker}`)[0].token,legacyToken);
assert.equal((await sql`SELECT count(*) AS n FROM bb_launch_fee_events WHERE fee_contract_address IS NULL`)[0].n,0n);
logs=[event(QUOTE_VAULT_ABI,"Claimed",vault,{tokenId:1n,account:wallet,currency:quote,amount:1n},6)];
await applyLaunchTx("base",hash); await applyLaunchTx("base",hash);
assert.equal((await walletQuoteFees(wallet)).get(`8453:${token}`),"1");
// A chain cursor ahead of the new factory must not skip its backfill.
await sql`UPDATE bb_launches SET factory_address=NULL,locker_address=NULL,fee_contract_address=NULL WHERE token=${legacyToken}`;
await sql`INSERT INTO bb_launch_sync_cursor (chain_id,cursor_block) VALUES(8453,100) ON CONFLICT(chain_id) DO UPDATE SET cursor_block=100`;
await pollLaunches("base");
const [cursor]=await sql`SELECT * FROM bb_launch_suite_cursor WHERE suite_id='quote-v2'`;
assert.equal(cursor.cursor_block,2n);
// The chain cursor follows the slowest suite (lp-v1 inherited 100), so health lag shows the backfill.
assert.equal((await sql`SELECT cursor_block FROM bb_launch_sync_cursor WHERE chain_id=8453`)[0].cursor_block,2n);
const [migrated]=await sql`SELECT factory_address,locker_address,fee_contract_address FROM bb_launches WHERE token=${legacyToken}`;
assert.deepEqual(migrated,{factory_address:legacyFactory,locker_address:legacyLocker,fee_contract_address:legacyLocker});
console.log("quote indexer: schema replay, dedupe, trader amounts, suite isolation, credit/claim earnings and cursor migration passed");
} finally { await sql.end(); }
13 changes: 8 additions & 5 deletions app/scripts/rebuild-launch-totals.sql
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,24 @@ LOCK TABLE bb_launches IN EXCLUSIVE MODE;

WITH s AS (
SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells,
COALESCE(sum(abs(amount0)), 0) AS volume_quote, max(block_time) AS last_trade_at
COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0) AS volume_quote, max(block_time) AS last_trade_at
FROM bb_launch_swaps GROUP BY 1, 2
)
SELECT 'drift before' AS what,
count(*) FILTER (WHERE l.buys <> s.buys OR l.sells <> s.sells OR l.volume_quote <> s.volume_quote) AS launches_with_wrong_totals,
count(*) FILTER (WHERE (l.buys + l.sells) > 0 AND s.token IS NULL) AS totals_without_swaps
FROM bb_launches l LEFT JOIN s ON s.chain_id = l.chain_id AND s.token = l.token;

-- swaps → buys / sells / volume / last trade
-- swaps → buys / sells / volume / accrued quote fees / last trade
-- (volume is the trader's quote amount: quote-only launches record it next to the pool's own delta)
UPDATE bb_launches l SET
buys = COALESCE(s.buys, 0), sells = COALESCE(s.sells, 0), volume_quote = COALESCE(s.volume_quote, 0), last_trade_at = s.last_trade_at
buys = COALESCE(s.buys, 0), sells = COALESCE(s.sells, 0), volume_quote = COALESCE(s.volume_quote, 0),
fees_quote_accrued = COALESCE(s.fees_quote_accrued, 0), last_trade_at = s.last_trade_at
FROM (SELECT l2.chain_id, l2.token,
(SELECT count(*) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token AND x.is_buy) AS buys,
(SELECT count(*) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token AND NOT x.is_buy) AS sells,
(SELECT COALESCE(sum(abs(amount0)), 0) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS volume_quote,
(SELECT COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS volume_quote,
(SELECT COALESCE(sum(quote_fee), 0) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS fees_quote_accrued,
(SELECT max(block_time) FROM bb_launch_swaps x WHERE x.chain_id = l2.chain_id AND x.token = l2.token) AS last_trade_at
FROM bb_launches l2) s
WHERE l.chain_id = s.chain_id AND l.token = s.token;
Expand All @@ -53,7 +56,7 @@ UPDATE bb_launches l SET
WHERE l.chain_id = f.chain_id AND l.token = f.token;

WITH s AS (
SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, COALESCE(sum(abs(amount0)), 0) AS volume_quote
SELECT chain_id, token, count(*) FILTER (WHERE is_buy) AS buys, count(*) FILTER (WHERE NOT is_buy) AS sells, COALESCE(sum(abs(COALESCE(trader_amount0, amount0))), 0) AS volume_quote
FROM bb_launch_swaps GROUP BY 1, 2
)
SELECT 'drift after' AS what,
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export default function AgentsPage() {
<section id="trade" tabIndex={-1} className={`${shell.anchorSection} ${styles.section}`} aria-labelledby="agents-trade-title">
<div className={styles.sectionHeading}><span className={styles.sectionIcon} aria-hidden="true"><ArrowLeftRight size={17} /></span><div><h2 id="agents-trade-title">Trade & collect</h2><p>Standard pools. Direct contract calls.</p></div></div>
<div className={styles.tradeReference}>
<div><span className={styles.tradeLabel}>Swap</span><h3>Speak Uniswap v4</h3><p className={styles.prose}>Pools are plain Uniswap v4, with tick spacing <code>200</code> and no hook. The quote can be ETH, TWIG (Base), GITLAWB (Base and Robinhood Chain), USDG (Robinhood Chain), USDC (Arc) or a supported issuer-registry stock token. Get the actual pool key with <code>poolKeyOf(token)</code> on the factory. Swap through the Universal Router with a <code>V4_SWAP</code> command, or any router that speaks v4.</p></div>
<div><span className={styles.tradeLabel}>Swap</span><h3>Speak Uniswap v4</h3><p className={styles.prose}>Pools use Uniswap v4 with tick spacing <code>200</code>. Legacy pools have no hook; quote-only pools have a fixed fee hook and zero LP fee. The quote can be ETH, TWIG (Base), GITLAWB (Base and Robinhood Chain), USDG (Robinhood Chain), USDC (Arc) or a supported issuer-registry stock token. Get the actual pool key with <code>poolKeyOf(token)</code> on the launch&apos;s recorded factory. Quote-only fees are collected from its recorded vault with <code>collect(tokenId)</code>, and credited fees with <code>claim(tokenId)</code>. Swap through the Universal Router with a <code>V4_SWAP</code> command, or any router that speaks v4.</p></div>
<div><span className={styles.tradeLabel}>Collect</span><h3>Pay out accrued fees</h3><p className={styles.prose}>Anyone may call <code>collect(tokenId)</code> on that chain&apos;s locker to pay out accrued fees. Base locker: <code>{locker}</code>. Use the matching chain address above.</p></div>
</div>
<p className={styles.exampleNote}>Tokenized stocks are third-party securities offered under Regulation S, not to US persons. The site recognizes them from issuer registries, not on-chain names.</p>
Expand Down
5 changes: 3 additions & 2 deletions app/src/app/api/me/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { getWalletTokens, getWalletTrades, listLaunches } from "@/lib/launchpad/queries";
import { getWalletTokens, getWalletTrades, listLaunches, walletQuoteFees } from "@/lib/launchpad/queries";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { memo } from "@/lib/launchpad/memo";

Expand All @@ -13,7 +13,8 @@ export async function GET(req: Request) {
try {
const usd = await ethUsd();
const data = await memo(`me:${w}`, 3_000, async () => {
const [launches, tokens, trades] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50)]);
const [launches, tokens, trades, paid] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50), walletQuoteFees(w)]);
for (const launch of launches) if (launch.suite_id === "quote-v2") launch.wallet_fees_quote_paid = paid.get(`${launch.chain_id}:${launch.token}`) ?? "0";
return { wallet: w, ethUsd: usd, launches, tokens, trades };
});
return NextResponse.json(data, { headers: { "cache-control": "no-store" } });
Expand Down
Loading