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
2 changes: 2 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ ARG NEXT_PUBLIC_LAUNCH_FACTORY
ARG NEXT_PUBLIC_LAUNCH_LOCKER
ARG NEXT_PUBLIC_LAUNCH_FACTORY_ROBINHOOD
ARG NEXT_PUBLIC_LAUNCH_LOCKER_ROBINHOOD
ARG NEXT_PUBLIC_LAUNCH_FACTORY_ARC
ARG NEXT_PUBLIC_LAUNCH_LOCKER_ARC
# Server-side reads of the same vars must match the client build. Everything
# secret (DATABASE_URL, RPC, deploy block) comes from Fly secrets at runtime — see LAUNCH.md.
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 \
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/launchpad/LaunchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D
<section className={`${card} p-5 space-y-4`}>
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<h2 className="text-sm font-semibold text-ink">Chain</h2>
<span className="text-xs text-muted">same contracts, same rules, on every chain</span>
<span className="text-xs text-muted">same launch, same rules, on every chain</span>
</div>
<div className="grid sm:grid-cols-3 gap-2">
{CHAIN_KEYS.map((k) => {
Expand Down
35 changes: 34 additions & 1 deletion app/src/lib/launchpad/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,35 @@ async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Pr

// ── holders ──────────────────────────────────────────────────────────────────
type TransferLog = Log & { args: { from: Address; to: Address; value: bigint } };

type RawReceiptLog = { address: string; topics: Hex[]; data: Hex; blockNumber: Hex; transactionHash: Hex; logIndex: Hex; removed?: boolean };

/**
* A token's Transfer logs in one block, read from the block's receipts (eth_getBlockReceipts is not subject to the
* per-call result cap that eth_getLogs has on some nodes). Null when the node does not offer the method.
*/
async function transfersFromBlockReceipts(chain: ChainKey, token: string, block: bigint): Promise<TransferLog[] | null> {
const client = publicClient(chain);
const want = token.toLowerCase();
try {
const receipts: unknown = await client.request({ method: "eth_getBlockReceipts" as never, params: [`0x${block.toString(16)}`] as never });
// the response is untyped RPC data: anything not shaped like receipts with logs counts as "unavailable", never a throw
if (!Array.isArray(receipts)) return null;
const raw: RawReceiptLog[] = [];
for (const r of receipts) {
const logs = (r as { logs?: unknown } | null)?.logs;
if (!Array.isArray(logs)) return null;
for (const l of logs as Partial<RawReceiptLog>[]) {
if (typeof l?.address !== "string" || !Array.isArray(l.topics) || typeof l.data !== "string" || typeof l.blockNumber !== "string" || typeof l.transactionHash !== "string" || typeof l.logIndex !== "string") return null;
if (l.address.toLowerCase() === want && !l.removed) raw.push(l as RawReceiptLog);
}
}
const parsed = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: raw.map((l) => ({ ...l, blockNumber: BigInt(l.blockNumber), logIndex: Number(l.logIndex) })) as unknown as Log[] });
return parsed as unknown as TransferLog[];
} catch {
return null;
}
}
const SYNCED_FOREVER = 9223372036854775807n; // bigint max = "history fully scanned; the live loop keeps it current"

/** System addresses that hold launched tokens on the protocol's behalf (never counted as holders): pool, position manager, locker, factory, plus router/Permit2 which keep swap dust. */
Expand Down Expand Up @@ -296,7 +325,11 @@ async function applyTransfers(db: Db, chain: ChainKey, tokens: string[], from: b
} catch (err) {
if (a !== b || !isRangeTooLarge(err)) throw err; // a range: let fetchLogsSplit halve the blocks
if (addrs.length <= 1) {
console.error(`[alert] launch-sync ${chain}: Transfer logs of ${addrs[0]} in block ${a} exceed the node's result cap; that block's holder update for the token is skipped and its holder balances are off by it from now on`);
// one token, one block, over the cap: the block's receipts are not capped, so read the token's Transfer logs from
// them; only if the node has no receipts method is the block skipped (an alert, since the balances stay off by it)
const recovered = await transfersFromBlockReceipts(chain, addrs[0], a);
if (recovered !== null) return recovered;
console.error(`[alert] launch-sync ${chain}: Transfer logs of ${addrs[0]} in block ${a} exceed the node's result cap and eth_getBlockReceipts is unavailable; that block's holder update for the token is skipped and its holder balances are off by it from now on`);
return [];
}
const mid = Math.ceil(addrs.length / 2);
Expand Down
2 changes: 1 addition & 1 deletion contracts/docs/LAUNCHPAD.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**LIVE on Base mainnet (block 50940130) and Robinhood Chain (block 55880172), 2026-09-06** — same addresses on both: LaunchFactory `0x815542E8b392389A1389E22E588E4B62A67Ade72`, LaunchLocker `0xcd1680D26922fcd9CabFbb8a56bA40C333fD842a` (`deployments/launchpad-base.json`, `deployments/launchpad-robinhood.json`). Robinhood default quote: USDG `0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168` (6 dec).

**Arc (5042): LIVE 2026-09-16 (block 21165817, tx `0x9814d588…6a4b7`)**, same addresses (`deployments/launchpad-arc.json`), deploying **`src/LaunchFactoryArc.sol`**: a verbatim copy of `LaunchFactory.sol` (which stays untouched, the Base / Robinhood source) with ONE guard: `launch` refuses `quote = address(0)` there (`NativeQuoteUnsupported`). `script/check-arc-factory.sh` fails if the two files drift apart in any other way. On Arc the native asset is USDC and the same balance is also the ERC-20 at `0x3600…`; `LaunchLocker` keeps one ledger per currency (`reserved[currency]`, `claimable`), so a native-quoted position would let a credited ERC-20 share (a payout on Circle's blocklist, and three sanctioned addresses are blocklisted on Arc's USDC already) be read as native surplus by another launch's `collect` and swept, after which every smaller USDC collect underflows. `test_fork_arc_withoutTheGuardCreditedUsdcWouldBeSwept` reproduces it with the guard disabled; `test_fork_arc_nativeQuotedPoolCannotSweepCreditedUsdc` pins the refusal. The locker is byte-identical to Base and Robinhood Chain. Only quote: USDC `0x3600000000000000000000000000000000000000` (6 dec; it is also the gas token, the native balance at 18 dec). Uniswap v4 there: PoolManager `0x8366a39CC670B4001A1121B8F6A443A643e40951`, PositionManager `0x6049c9a0e26405C0985f9E3685C87d0aE917f82B`, Universal Router `0x4fcA4a51Ab4F23A7447b3284fBd7D73289A89Fb1` (v2.1.1, "v2" swap layout). `test/LaunchFactory.arc.fork.t.sol` proves launch → buy → collect and the router layout against the live chain; it runs only under Circle's [arc-foundry](https://github.com/circlefin/arc-foundry) (`FOUNDRY_PROFILE=arc arc-forge test`), since upstream forge cannot execute USDC transfers on Arc.
**Arc (5042): LIVE 2026-09-16 (block 21165817, tx `0x9814d588…6a4b7`)**, same addresses (`deployments/launchpad-arc.json`), deploying **`src/LaunchFactoryArc.sol`**: a verbatim copy of `LaunchFactory.sol` (which stays untouched, the Base / Robinhood source) with ONE guard: `launch` refuses `quote = address(0)` there (`NativeQuoteUnsupported`). `script/check-arc-factory.sh` fails if the two files drift apart in any other way. The copied NatSpec still describes native ETH as a quote; on Arc that path reverts (the guard wins), and the comments are left as deployed because the verified source must stay byte-identical to what is on-chain. On Arc the native asset is USDC and the same balance is also the ERC-20 at `0x3600…`; `LaunchLocker` keeps one ledger per currency (`reserved[currency]`, `claimable`), so a native-quoted position would let a credited ERC-20 share (a payout on Circle's blocklist, and three sanctioned addresses are blocklisted on Arc's USDC already) be read as native surplus by another launch's `collect` and swept, after which every smaller USDC collect underflows. `test_fork_arc_withoutTheGuardCreditedUsdcWouldBeSwept` reproduces it with the guard disabled; `test_fork_arc_nativeQuotedPoolCannotSweepCreditedUsdc` pins the refusal. The locker is byte-identical to Base and Robinhood Chain. Only quote: USDC `0x3600000000000000000000000000000000000000` (6 dec; it is also the gas token, the native balance at 18 dec). Uniswap v4 there: PoolManager `0x8366a39CC670B4001A1121B8F6A443A643e40951`, PositionManager `0x6049c9a0e26405C0985f9E3685C87d0aE917f82B`, Universal Router `0x4fcA4a51Ab4F23A7447b3284fBd7D73289A89Fb1` (v2.1.1, "v2" swap layout). `test/LaunchFactory.arc.fork.t.sol` proves launch → buy → collect and the router layout against the live chain; it runs only under Circle's [arc-foundry](https://github.com/circlefin/arc-foundry) (`FOUNDRY_PROFILE=arc arc-forge test`), since upstream forge cannot execute USDC transfers on Arc.

Three contracts, no platform fee, no owner:

Expand Down
7 changes: 5 additions & 2 deletions contracts/script/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# Blockscout (Robinhood/Base/Arc): keyless.
# Usage: script/verify.sh [base|robinhood|arc|all] (default: all)
set -euo pipefail
failed=0
cd "$(dirname "$0")/.."
[ -f .env ] && set -a && . ./.env && set +a
KEY="${ETHERSCAN_API_KEY:-${BASESCAN_API_KEY:-}}"
Expand Down Expand Up @@ -32,9 +33,9 @@ verify_pair() { # <chain-id> <pm> <posm> <verifier args...> (FACTORY_SRC: the
fargs=$(cast abi-encode "constructor(address,address,address)" "$pm" "$posm" "$PERMIT2")
largs=$(cast abi-encode "constructor(address)" "$posm")
echo "== chain $chain: ${src##*:}"
forge verify-contract --chain "$chain" "$factory" "$src" --constructor-args "$fargs" "${common[@]}" "$@" || true
forge verify-contract --chain "$chain" "$factory" "$src" --constructor-args "$fargs" "${common[@]}" "$@" || { echo "!! ${src##*:} on chain $chain NOT verified"; failed=$((failed + 1)); }
echo "== chain $chain: LaunchLocker"
forge verify-contract --chain "$chain" "$locker" src/LaunchLocker.sol:LaunchLocker --constructor-args "$largs" "${common[@]}" "$@" || true
forge verify-contract --chain "$chain" "$locker" src/LaunchLocker.sol:LaunchLocker --constructor-args "$largs" "${common[@]}" "$@" || { echo "!! LaunchLocker on chain $chain NOT verified"; failed=$((failed + 1)); }
}

target=${1:-all}
Expand All @@ -53,3 +54,5 @@ if [[ $target == arc || $target == all ]]; then
echo "== Arc Blockscout (keyless)"
VERIFY_FACTORY=${ARC_FACTORY:-} VERIFY_LOCKER=${ARC_LOCKER:-} FACTORY_SRC=src/LaunchFactoryArc.sol:LaunchFactoryArc verify_pair 5042 "$ARC_PM" "$ARC_POSM" --verifier blockscout --verifier-url https://explorer.arc.io/api/
fi
if [ "$failed" -gt 0 ]; then echo "$failed verification(s) failed"; exit 1; fi
echo "all verifications submitted"
Loading