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/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { WhoAvatar, WhoName } from "@/components/profile/Who";
import BoardRefresh from "@/components/points/BoardRefresh";
import YourStanding from "@/components/points/YourStanding";
import { memo } from "@/lib/launchpad/memo";
import { RULES } from "@/lib/points/score";
import { RULES, rulesFor } from "@/lib/points/score";
import { boardRows, publicSeason, seasonEnded, seasonFinal, type Board } from "@/lib/points/server";
import { pageMetadata } from "@/lib/seo";

Expand Down Expand Up @@ -60,7 +60,7 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
<div>
<p className="inline-flex min-h-7 items-center gap-1.5 rounded-full border border-line px-3 text-xs font-medium text-body"><Trophy size={14} className="text-brand" aria-hidden="true" />{season.name}</p>
<h1 className="mt-4 text-[32px] font-bold leading-[1.1] tracking-[-0.04em] text-ink sm:text-[44px]">Leaderboard</h1>
<p className="mt-2 max-w-xl text-sm text-body text-pretty">Real buyers and early finds, read from the chain every hour. Points are reputation: no cash value, not a token. <Link href="/rules#points" className="underline underline-offset-4 hover:text-ink">How points work</Link></p>
<p className="mt-2 max-w-xl text-sm text-body text-pretty">Real buyers and early finds, read from the chain every hour. Points are reputation: no cash value, not a token. <Link href="/rules#points" className="underline underline-offset-4 hover:text-ink">How points work</Link>{rulesFor(season.rules_version).bonus ? <> · This season adds <Link href="/rules#bonuses" className="underline underline-offset-4 hover:text-ink">trading bonuses</Link> for trades in the app and creator volume milestones.</> : null}</p>
</div>
<div className="text-left sm:text-right">
{ended ? (
Expand Down
7 changes: 6 additions & 1 deletion app/src/app/rules/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ 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 } from "@/lib/points/score";
import { RULES, RULES_V2 } from "@/lib/points/score";

/** The trading bonuses of the latest rules (seasons started on version 2 or later). */
const BONUS = RULES_V2.bonus!;
const n = (v: number) => v.toLocaleString("en-US");

export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." });

Expand Down Expand Up @@ -129,6 +133,7 @@ export default function RulesPage() {
<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 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>
</section>
Expand Down
14 changes: 11 additions & 3 deletions 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, swapInApp } from "./appTag.ts";
import { HANDLE_OPS_ABI, appTagOf, endsWithSuffix, swapInApp } from "./appTag.ts";
import { BUILDER_DATA_SUFFIX } from "../chainPublic.ts";

const ROUTER = "0x6ff5693b99212da76ad316178a184ab56d299b43";
Expand All @@ -21,9 +21,17 @@ const op07 = (sender: string, callData: Hex) => ({ sender: sender as Hex, nonce:
const bundle06 = (ops: ReturnType<typeof op06>[]) => encodeFunctionData({ abi: [HANDLE_OPS_ABI[0]], args: [ops, OTHER as Hex] });
const bundle07 = (ops: ReturnType<typeof op07>[]) => encodeFunctionData({ abi: [HANDLE_OPS_ABI[1]], args: [ops, OTHER as Hex] });

test("a wallet's own router call is in-app with the suffix (also when its wallet appends a code after ours)", () => {
// another wallet's own ERC-8021 suffix ("bc_other", schema 0), as it would append after ours
const OTHER_CODE = "62635f6f74686572" + "08" + "00" + "80218021802180218021802180218021";

test("a wallet's own router call is in-app with the suffix at the end (or followed only by another builder suffix)", () => {
assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail, router: ROUTER, suffix: SUFFIX }).direct, true);
assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail + "deadbeef", router: ROUTER, suffix: SUFFIX }).direct, true);
assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail + OTHER_CODE, router: ROUTER, suffix: SUFFIX }).direct, true);
// anything else after it, or the same bytes inside the swap's arguments, is not our suffix
assert.equal(appTagOf({ to: ROUTER, input: swapCall + tail + "deadbeef", router: ROUTER, suffix: SUFFIX }).direct, false);
const embedded = encodeFunctionData({ abi: execute, args: ["0x10", [`0x${tail}`], 1n] });
assert.equal(appTagOf({ to: ROUTER, input: embedded, router: ROUTER, suffix: SUFFIX }).direct, false);
assert.equal(endsWithSuffix("ab" + "00".repeat(300) + "80218021802180218021802180218021", "ab"), false, "a tail longer than any suffix");
assert.equal(appTagOf({ to: ROUTER, input: swapCall, router: ROUTER, suffix: SUFFIX }).direct, false);
// the suffix on a call to anything but the router (a bot contract, an aggregator) is not in-app
assert.equal(appTagOf({ to: OTHER, input: swapCall + tail, router: ROUTER, suffix: SUFFIX }).direct, false);
Expand Down
23 changes: 19 additions & 4 deletions app/src/lib/launchpad/appTag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
*
* Every transaction the app sends carries its ERC-8021 builder-code suffix (chainPublic.BUILDER_DATA_SUFFIX) on the
* calldata. A swap counts as in-app when:
* - the transaction was sent to the chain's Universal Router and its input carries the suffix (a wallet sending its
* own transaction: the suffix ends the input, unless the wallet appended a builder code of its own after it), or
* - 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).
* 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).
* 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
Expand All @@ -33,6 +35,19 @@ export type AppTag = { direct: boolean; senders: readonly string[] };

const NONE: AppTag = { direct: false, senders: [] };

/** ERC-8021's closing marker: the last 16 bytes of every builder-code suffix. */
const ERC8021_MARKER = "80218021802180218021802180218021";
/** The longest tail another wallet's suffix may add after ours (hex characters: 256 bytes). */
const MAX_TAIL_HEX = 512;

/** Whether `input` ends with `needle`, or with `needle` followed only by another ERC-8021 suffix. */
export function endsWithSuffix(input: string, needle: string): boolean {
const at = input.lastIndexOf(needle);
if (at < 0) return false;
const rest = input.slice(at + needle.length);
return rest === "" || (rest.length % 2 === 0 && rest.length <= MAX_TAIL_HEX && rest.endsWith(ERC8021_MARKER));
}

/** The suffix as bare lowercase hex (no 0x), for searching an input. */
function bare(suffix: string): string {
const s = suffix.toLowerCase();
Expand All @@ -45,7 +60,7 @@ export function appTagOf(p: { to: string | null; input: string; router: string;
const needle = bare(p.suffix);
if (!to || needle.length === 0) return NONE;
const input = p.input.toLowerCase();
if (to === p.router.toLowerCase()) return { direct: input.includes(needle), senders: [] };
if (to === p.router.toLowerCase()) return { direct: endsWithSuffix(input, needle), senders: [] };
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 });
Expand Down
Loading
Loading