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
4 changes: 2 additions & 2 deletions app/src/app/rules/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import LaunchMachine from "@/components/launchpad/LaunchMachine";
import { ChainLogo } from "@/components/launchpad/ChainLogo";
import BoostRulesEntry from "@/components/launchpad/BoostRulesEntry";
import { boostEnabled } from "@/lib/launchpad/boostServer";
import { RULES, RULES_V2 } from "@/lib/points/score";
import { RULES, RULES_V2, eligibleNeededFor } from "@/lib/points/score";

/** The trading bonuses of the latest rules (seasons started on version 2 or later). */
const BONUS = RULES_V2.bonus!;
Expand Down Expand Up @@ -132,7 +132,7 @@ export default function RulesPage() {
<div className={styles.questions}>
<details open><summary>What counts as a real buyer?<ChevronDown size={17} aria-hidden="true" /></summary><div><p>A wallet whose first buy of a token was worth ${RULES.minTradeUsd} or more when it was made, after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. The wallet must receive the tokens of that buy itself (a relayer or router that only passed them on is not a buyer). Never the launcher, a fee recipient, a wallet that got ${RULES.linkMinUsd} or more of the token from either before its first buy, or a wallet kept off points. A real holder is a real buyer with ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below) of that first buy still in the wallet, valued at the token&apos;s steady price (the middle of its last trade price now, 12 hours ago and 24 hours ago, so a last-minute pump or dump moves nothing).</p><p>Where a rule needs a number of holders, every holder with a qualifying profile counts, and holders without one count up to twice as many as those (at least {RULES.unverifiedFree}). The same limit applies to what a creator is paid for holders without a qualifying profile.</p><p>Holding is judged per buy: whenever tokens leave a wallet (sold, or moved to any other wallet), the most recently received ones go first. So selling a later buy does not touch an earlier one, tokens sent in from another wallet never stand in for a buy, and moving a position out counts the same as selling it. Only tokens priced in a quote we can price count.</p></div></details>
<details open><summary>How do creators earn?<ChevronDown size={17} aria-hidden="true" /></summary><div><p>On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.</p><p>Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 for each buy that is still at least half held (a buy sold straight back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold or moved tokens out during the season (to any wallet, including one of your own; burning them is fine) and less than half of your own buys of it is left.</p></div></details>
<details open><summary>How do traders earn?<ChevronDown size={17} aria-hidden="true" /></summary><div><p>Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} counted holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.</p><p>Every token with {RULES.holdMinHolders}+ counted holders that you first buy this season, with a real first buy, and still half-hold a day later (that buy itself, not a later one; a token you already held before the season does not count): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people&apos;s tokens with {RULES.scoutFeeMinHolders}+ counted holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.</p></div></details>
<details open><summary>How do traders earn?<ChevronDown size={17} aria-hidden="true" /></summary><div><p>Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} counted holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.</p><p>Every token with {RULES.holdMinHolders}+ counted holders that you first buy this season, with a real first buy, and still half-hold a day later (that buy itself, not a later one; a token you already held before the season does not count): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people&apos;s tokens with {RULES.scoutFeeMinHolders}+ counted holders (which takes at least {Math.max(RULES.scoutFeeMinEligibleHolders, eligibleNeededFor(RULES.scoutFeeMinHolders))} with a qualifying profile), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.</p></div></details>
<details open id="bonuses"><summary>Trading bonuses<ChevronDown size={17} aria-hidden="true" /></summary><div><p>Seasons that include these say so on the leaderboard (a season keeps the rules it started with). Trading in the app: a buy you make on openlaunch, held as above, earns {BONUS.appFeePointsPerUsd} points per $1 of fees instead of {RULES.scoutFeePointsPerUsd}. Selling in the app tokens you bought this season a week or more earlier earns {BONUS.sellFeePointsPerUsd} points per $1 of fees on that part of the sale. In one day, other buys earn up to {RULES.scoutFeeCapPerDay} and trades in the app top that up to {BONUS.scoutFeeCapPerDayApp}. A buy you held at least half of for a week keeps its fee points (yours and the creator&apos;s) after you sell it.</p><p>Volume milestones for creators: a token&apos;s trades this season of ${RULES.minTradeUsd} or more with money at stake (buys still held, or held a week; sells of buys made a week or more before, so buying and selling straight back counts for nothing), counting each trader for at most ${n(BONUS.volumeTraderCapUsd)} a day across all your tokens (${BONUS.volumeUnverifiedTraderCapUsd} without a qualifying profile). Reaching {BONUS.volumeTiers.map((t) => `$${n(t.usd)}`).join(", ")} of that, with {BONUS.volumeTiers.map((t) => t.traders).join(", ")} different traders with a qualifying profile, adds {BONUS.volumeTiers.map((t) => n(t.pts)).join(", ")} points ({n(BONUS.volumeTiers.reduce((a, t) => a + t.pts, 0))} in all), on a token with {BONUS.volumeMinHolders} counted holders that you did not dump.</p></div></details>
<details open><summary>Who appears on the leaderboard?<ChevronDown size={17} aria-hidden="true" /></summary><div><p>Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far with the next points update after the profile qualifies (points update about every hour). The post itself earns nothing.</p></div></details>
</div>
Expand Down
22 changes: 21 additions & 1 deletion app/src/lib/launchpad/appTag.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { encodeFunctionData, parseAbi, type Hex } from "viem";
import { HANDLE_OPS_ABI, appTagOf, endsWithSuffix, swapInApp } from "./appTag.ts";
import { ACCOUNT_CALL_ABI, HANDLE_OPS_ABI, accountCalls, appTagOf, endsWithSuffix, swapInApp } from "./appTag.ts";
import { BUILDER_DATA_SUFFIX } from "../chainPublic.ts";

const ROUTER = "0x6ff5693b99212da76ad316178a184ab56d299b43";
Expand Down Expand Up @@ -52,6 +52,14 @@ test("a smart wallet's operation is in-app only for the account whose own callDa
assert.equal(swapInApp(tag, "0x00000000000000000000000000000000000000cc", "tx_from"), false, "credited to the bundler");
}
assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, plain)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] });
// the suffix on an operation that calls something other than the router (a bot contract, an aggregator) is not in-app
const elsewhere = encodeFunctionData({ abi: smartExecute, args: [OTHER as Hex, 0n, (swapCall + tail) as Hex] });
assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, elsewhere)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] });
// nor one that only names the router inside its arguments, or a router call with the suffix buried in its arguments
const namesRouter = encodeFunctionData({ abi: smartExecute, args: [OTHER as Hex, 0n, (encodeFunctionData({ abi: parseAbi(["function go(address r, bytes d)"]), args: [ROUTER, swapCall] }) + tail) as Hex] });
assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, namesRouter)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] });
const buried = opCall(encodeFunctionData({ abi: execute, args: ["0x10", [`0x${tail}`], 1n] }));
assert.deepEqual(appTagOf({ to: EP06, input: bundle06([op06(ACCOUNT, buried)]), router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] });
assert.deepEqual(appTagOf({ to: EP06, input: "0xdeadbeef" + tail, router: ROUTER, suffix: SUFFIX }), { direct: false, senders: [] }, "not a bundle");
});

Expand All @@ -60,3 +68,15 @@ test("an unread transaction is unknown, never in-app", () => {
assert.equal(swapInApp({ direct: true, senders: [] }, null, null), null);
assert.equal(swapInApp({ direct: true, senders: [] }, ACCOUNT, "tx_from"), true);
});

test("smart-account call formats: Coinbase Smart Wallet execute and executeBatch, SimpleAccount batches; others are not read", () => {
const callData = (swapCall + tail) as Hex;
const formats = [
encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[0]], args: [ROUTER, 0n, callData] }),
encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[1]], args: [[{ target: OTHER as Hex, value: 0n, data: "0x" }, { target: ROUTER, value: 0n, data: callData }]] }),
encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[2]], args: [[OTHER as Hex, ROUTER], ["0x", callData]] }),
encodeFunctionData({ abi: [ACCOUNT_CALL_ABI[3]], args: [[ROUTER], [0n], [callData]] }),
];
for (const cd of formats) assert.deepEqual(appTagOf({ to: EP07, input: bundle07([op07(ACCOUNT, cd as Hex)]), router: ROUTER, suffix: SUFFIX }).senders, [ACCOUNT], cd.slice(0, 10));
assert.deepEqual(accountCalls("0xdeadbeef"), []);
});
44 changes: 32 additions & 12 deletions app/src/lib/launchpad/appTag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,48 @@
* - the transaction was sent to the chain's Universal Router and its input ends with the suffix (a wallet sending
* its own transaction), or with the suffix followed only by another ERC-8021 suffix (a wallet that appends a
* builder code of its own); the same bytes inside the swap's arguments do not count, or
* - it was sent to an ERC-4337 EntryPoint, and the user operation of the account the swap is credited to carries
* the suffix in its callData (a smart wallet: the app's call, suffix and all, sits inside the operation; ABI
* encoding pads that call, so there the suffix is never last and is looked for anywhere in the callData).
* - it was sent to an ERC-4337 EntryPoint, and the user operation of the account the swap is credited to makes a
* call TO the Universal Router whose data ends with the suffix, read by decoding the account's call
* (execute(address,uint256,bytes) of Coinbase Smart Wallet and SimpleAccount, or one of their executeBatch forms).
* An operation calling a bot contract or an aggregator, one that only names the router in its arguments, or a
* smart account whose call format is not one of those, does not count.
* Anything else (an aggregator, a bot contract, another front end's smart-wallet trade) is not in-app.
*
* The suffix is public, so anyone can copy it: this is a label for a small, capped points bonus, never proof. It is
* worked out once per transaction and only its result is kept (the input itself can be kilobytes).
*/
import { decodeFunctionData, parseAbi, type Hex } from "viem";

/** ERC-4337 EntryPoints (same address on every chain): v0.6, v0.7, v0.8, v0.9. Lowercase; kept equal to attribution.ts. */
const ENTRY_POINTS: ReadonlySet<string> = new Set([
"0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789",
"0x0000000071727de22e5e9d8baf0edac6f37da032",
"0x4337084d9e255ff0702461cf8895ce9e3b5ff108",
"0x433709009b8330fda32311df1c2afa402ed8d009",
]);
import { ENTRY_POINTS } from "./attribution.ts";

/** handleOps for v0.6 (UserOperation) and v0.7+ (PackedUserOperation): different tuples, so different selectors. */
export const HANDLE_OPS_ABI = parseAbi([
"function handleOps((address sender,uint256 nonce,bytes initCode,bytes callData,uint256 callGasLimit,uint256 verificationGasLimit,uint256 preVerificationGas,uint256 maxFeePerGas,uint256 maxPriorityFeePerGas,bytes paymasterAndData,bytes signature)[] ops, address beneficiary)",
"function handleOps((address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature)[] ops, address beneficiary)",
]);

/** The smart-account call formats read for the app tag (Coinbase Smart Wallet; SimpleAccount v0.6 and v0.7). */
export const ACCOUNT_CALL_ABI = parseAbi([
"function execute(address target, uint256 value, bytes data)",
"function executeBatch((address target, uint256 value, bytes data)[] calls)",
"function executeBatch(address[] dest, bytes[] func)",
"function executeBatch(address[] dest, uint256[] value, bytes[] func)",
]);

/** The (target, data) calls a smart account's operation makes, or [] for a call format not read here. */
export function accountCalls(callData: string): { target: string; data: string }[] {
try {
const d = decodeFunctionData({ abi: ACCOUNT_CALL_ABI, data: callData as Hex });
if (d.functionName === "execute") return [{ target: (d.args[0] as string).toLowerCase(), data: (d.args[2] as string).toLowerCase() }];
const a = d.args as readonly unknown[];
if (a.length === 1) return (a[0] as readonly { target: string; data: string }[]).map((c) => ({ target: c.target.toLowerCase(), data: c.data.toLowerCase() }));
const targets = a[0] as readonly string[];
const datas = (a.length === 2 ? a[1] : a[2]) as readonly string[];
return targets.map((t, i) => ({ target: t.toLowerCase(), data: (datas[i] ?? "0x").toLowerCase() }));
} catch {
return [];
}
}

/** What one transaction says about the app: `direct` for a wallet's own router call, `senders` for smart wallets. */
export type AppTag = { direct: boolean; senders: readonly string[] };

Expand Down Expand Up @@ -64,7 +82,9 @@ export function appTagOf(p: { to: string | null; input: string; router: string;
if (!ENTRY_POINTS.has(to) || !input.includes(needle)) return NONE; // cheap: no suffix anywhere, no operation has it
try {
const { args } = decodeFunctionData({ abi: HANDLE_OPS_ABI, data: p.input as Hex });
const senders = args[0].filter((op) => op.callData.toLowerCase().includes(needle)).map((op) => op.sender.toLowerCase());
const router = p.router.toLowerCase();
// the same call must go to the router and carry the suffix at its end, as a wallet's own router call would
const senders = args[0].filter((op) => accountCalls(op.callData).some((c) => c.target === router && endsWithSuffix(c.data, needle))).map((op) => op.sender.toLowerCase());
return { direct: false, senders: [...new Set(senders)] };
} catch {
return NONE; // handleAggregatedOps, or an input that is not a bundle: not tagged
Expand Down
8 changes: 6 additions & 2 deletions app/src/lib/launchpad/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ export async function healAppTags(chain: ChainKey): Promise<number> {
for (const [hash, swaps] of byTx) {
if (read >= 20) break;
const key = `app:${chain}:${hash}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// checked again here: a pass running alongside (the loop and a manual sync on one machine) may have just put it to rest
if (Date.now() < (healRestUntil.get(key) ?? 0)) continue;
read++;
const tx = await txOf(chain, hash as Hex);
Expand Down Expand Up @@ -968,7 +969,8 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise<ApplyLa
const to = receipt.to ? receipt.to.toLowerCase() : null;
const input = sent?.input ?? null;
if (txFacts.size > 5000) txFacts.clear();
txFacts.set(k, { from: receipt.from.toLowerCase(), to, tag: input === null ? null : tagOf(chain, to, input) });
const seeded: TxFacts = { from: receipt.from.toLowerCase(), to, tag: input === null ? null : tagOf(chain, to, input) };
txFacts.set(k, seeded);
if (receiptLogs.size > 500) receiptLogs.clear();
receiptLogs.set(k, receipt.logs.map((l) => ({ address: l.address, topics: l.topics as readonly string[], logIndex: Number(l.logIndex) })));
try {
Expand Down Expand Up @@ -1010,6 +1012,8 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise<ApplyLa
}
return { status: "applied", launches, swaps, fees, boosts, tokens };
} finally {
if (input === null) txFacts.delete(k); // unknown: the log poll reads the transaction again
// unknown: the log poll (or healAppTags) reads the transaction again. Only this call's own entry is removed: a sync
// of the same transaction running alongside may have cached the real tag meanwhile
if (input === null && txFacts.get(k) === seeded) txFacts.delete(k);
}
}
5 changes: 5 additions & 0 deletions app/src/lib/points/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ test("the compute's log line: counts and time, a warning once a run gets heavy",
assert.equal(computeLogLine("s2", { wallets: 3, eligible: 1, ms: COMPUTE_WARN_MS + 1, read }).level, "warn");
assert.equal(computeLogLine("s2", { wallets: 3, eligible: 1, ms: 5, read: { ...read, moves: COMPUTE_WARN_ROWS } }).level, "warn");
assert.deepEqual(computeLogLine("s2", { skipped: "no ETH price" }), { level: "log", line: "[points] s2: skipped (no ETH price), last board kept" });
// every source under the threshold, their total over it: heavy
assert.equal(computeLogLine("s2", { wallets: 3, eligible: 1, ms: 5, read: { ...read, swaps: 500_000, moves: 500_001 } }).level, "warn");
// a skipped run is timed too, and warned when slow
assert.deepEqual(computeLogLine("s2", { skipped: "no price for twig" }, 1_200), { level: "log", line: "[points] s2: skipped (no price for twig), last board kept after 1200ms" });
assert.equal(computeLogLine("s2", { skipped: "no price for twig" }, COMPUTE_WARN_MS + 1).level, "warn");
});
12 changes: 9 additions & 3 deletions app/src/lib/points/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ export type ComputeResult = { wallets: number; eligible: number; ms: number; rea
export const COMPUTE_WARN_ROWS = 1_000_000;
export const COMPUTE_WARN_MS = 60_000;

/** The log line of one run: counts and time, flagged when the run is getting heavy (pure). */
export function computeLogLine(slug: string, r: ComputeResult): { level: "log" | "warn"; line: string } {
if ("skipped" in r) return { level: "log", line: `[points] ${slug}: skipped (${r.skipped}), last board kept` };
/**
* The log line of one run: counts and time, flagged when the run is getting heavy (pure). `elapsedMs` (the caller's
* clock) times a skipped run too: one skipped late (a price missing after the season was read) can be heavy as well.
*/
export function computeLogLine(slug: string, r: ComputeResult, elapsedMs?: number): { level: "log" | "warn"; line: string } {
if ("skipped" in r) {
const slow = elapsedMs !== undefined && elapsedMs > COMPUTE_WARN_MS;
return { level: slow ? "warn" : "log", line: `[points] ${slug}: skipped (${r.skipped}), last board kept${elapsedMs !== undefined ? ` after ${elapsedMs}ms` : ""}${slow ? ` (heavy: over ${COMPUTE_WARN_MS / 1000}s)` : ""}` };
}
const rows = r.read ? r.read.swaps + r.read.firstBuys + r.read.moves + r.read.linked : 0;
const heavy = rows > COMPUTE_WARN_ROWS || r.ms > COMPUTE_WARN_MS;
const read = r.read ? ` from ${r.read.swaps} swaps, ${r.read.firstBuys} first buys, ${r.read.moves} moves, ${r.read.linked} linked transfers` : "";
Expand Down
Loading
Loading