diff --git a/app/Dockerfile b/app/Dockerfile index 6d60f07..44a8534 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -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 \ diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 3a32284..b591e73 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -429,7 +429,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D

Chain

- same contracts, same rules, on every chain + same launch, same rules, on every chain
{CHAIN_KEYS.map((k) => { diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index de55a97..28b3c4e 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -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 { + 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[]) { + 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. */ @@ -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); diff --git a/contracts/docs/LAUNCHPAD.md b/contracts/docs/LAUNCHPAD.md index 09d9e91..2a06170 100644 --- a/contracts/docs/LAUNCHPAD.md +++ b/contracts/docs/LAUNCHPAD.md @@ -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: diff --git a/contracts/script/verify.sh b/contracts/script/verify.sh index 986c486..721e591 100755 --- a/contracts/script/verify.sh +++ b/contracts/script/verify.sh @@ -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:-}}" @@ -32,9 +33,9 @@ verify_pair() { # (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} @@ -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"