// SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {Test} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {MockPonsFactory} from "./mocks/MockPons.sol"; import {IPonsFactory} from "../src/pons/IPons.sol"; import {SightlineLauncher} from "../src/pons/SightlineLauncher.sol"; contract RejectsEth { receive() external payable { revert("no"); } } contract SightlineLauncherTest is Test { MockPonsFactory pons; SightlineLauncher launcher; address alice = makeAddr("alice"); address bob = makeAddr("bob"); address[] NONE; function setUp() public { pons = new MockPonsFactory(); launcher = new SightlineLauncher(IPonsFactory(address(pons))); vm.deal(alice, 10 ether); vm.deal(bob, 10 ether); } function _input() internal pure returns (SightlineLauncher.LaunchInput memory p) { p.name = "United States Grand Prix"; p.symbol = "USGP26"; p.description = "Round 18, Circuit of the Americas."; p.twitter = "https://x.com/someone"; p.creatorTaxBps = 200; p.expectedEconomics = keccak256("econ"); p.category = "upcoming"; p.eventId = "f1:2026-18"; p.latE6 = 30_132_800; p.lngE6 = -97_641_100; } function test_launch_signsAndForwardsParams() public { vm.prank(alice); (address token, address curve,) = launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); assertEq(pons.lastLauncher(), address(launcher), "Sightline is the launcher"); ( string memory name, string memory symbol,, string memory description, IPonsFactory.Socials memory socials, address feeRecipient, uint16 tax, bool buyback,, ) = pons.last(); assertEq(name, "STLN - United States Grand Prix", "name signature"); assertEq(symbol, "USGP26"); assertEq(description, "Round 18, Circuit of the Americas."); assertEq(socials.website, "https://trysightline.io/l/0", "website is the token's Sightline page"); assertEq(socials.twitter, "https://x.com/someone"); assertEq(feeRecipient, alice, "creator earns the fees"); assertEq(tax, 200); assertFalse(buyback); SightlineLauncher.Launch memory l = launcher.getLaunch(token); assertEq(l.curve, curve); assertEq(l.creator, alice); assertEq(l.eventId, "f1:2026-18"); assertEq(l.category, "upcoming"); assertEq(l.latE6, 30_132_800); assertEq(l.createdBlock, block.number); assertEq(launcher.launchCount(), 1); } function test_launch_websiteCannotBeOverridden() public { // There's no website field in the input at all; check the stored value regardless of other socials. SightlineLauncher.LaunchInput memory p = _input(); p.telegram = "https://t.me/x"; vm.prank(alice); launcher.launch{value: 0.0005 ether}(p, NONE, 0); (,,,, IPonsFactory.Socials memory socials,,,,,) = pons.last(); assertEq(socials.website, "https://trysightline.io/l/0"); assertEq(socials.telegram, "https://t.me/x"); } function test_launch_websiteFollowsLaunchIndex() public { vm.startPrank(alice); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); vm.stopPrank(); (,,,, IPonsFactory.Socials memory socials,,,,,) = pons.last(); assertEq(socials.website, "https://trysightline.io/l/2"); } function test_launch_validatesName() public { SightlineLauncher.LaunchInput memory p = _input(); p.name = ""; vm.prank(alice); vm.expectRevert(SightlineLauncher.BadName.selector); launcher.launch{value: 0.0005 ether}(p, NONE, 0); p.name = string(new bytes(49)); vm.prank(alice); vm.expectRevert(SightlineLauncher.BadName.selector); launcher.launch{value: 0.0005 ether}(p, NONE, 0); } function test_launch_recordsArbitrumBlockWhenAvailable() public { // Off Arbitrum there's no ArbSys: block.number. With ArbSys: its L2 block number. vm.roll(1234); vm.prank(alice); (address token,,) = launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); assertEq(launcher.getLaunch(token).createdBlock, 1234); vm.etch(address(100), hex"fe"); vm.mockCall(address(100), abi.encodeWithSignature("arbBlockNumber()"), abi.encode(uint256(88_000_000))); vm.prank(alice); (address token2,,) = launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); assertEq(launcher.getLaunch(token2).createdBlock, 88_000_000); } function test_launch_forwardsSnipeExemptions() public { address[] memory ex = new address[](2); ex[0] = bob; ex[1] = makeAddr("team"); vm.prank(alice); launcher.launch{value: 0.0005 ether}(_input(), ex, 0); address[] memory got = pons.lastExemptions(); assertEq(got.length, 2); assertEq(got[0], bob); } function test_launch_capsSnipeExemptions() public { address[] memory ex = new address[](31); for (uint256 i = 0; i < 31; i++) ex[i] = address(uint160(i + 1)); vm.prank(alice); vm.expectRevert(SightlineLauncher.TooManyExemptions.selector); launcher.launch{value: 0.0005 ether}(_input(), ex, 0); } function test_launch_devBuyGoesToCreator() public { vm.prank(alice); (address token,, uint256 out) = launcher.launch{value: 0.1005 ether}(_input(), NONE, 1); assertGt(out, 0); assertEq(ERC20(token).balanceOf(alice), out); assertEq(ERC20(token).balanceOf(address(launcher)), 0); assertEq(address(launcher).balance, 0); assertEq(alice.balance, 10 ether - 0.1005 ether); } function test_launch_devBuySlippage() public { vm.prank(alice); vm.expectRevert("slippage"); launcher.launch{value: 0.1005 ether}(_input(), NONE, type(uint256).max); } function test_launch_refundsUnspentEth() public { // The curve fills up mid-buy and hands back half; the creator gets it, the launcher keeps nothing. pons.setNextRefundBps(5_000); vm.prank(alice); launcher.launch{value: 1.0005 ether}(_input(), NONE, 0); assertEq(address(launcher).balance, 0); assertEq(alice.balance, 10 ether - 0.5005 ether); } function test_launch_requiresFee() public { vm.prank(alice); vm.expectRevert(SightlineLauncher.InsufficientLaunchFee.selector); launcher.launch{value: 0.0004 ether}(_input(), NONE, 0); } function test_launch_validatesEvent() public { SightlineLauncher.LaunchInput memory p = _input(); p.eventId = ""; vm.prank(alice); vm.expectRevert(SightlineLauncher.BadEvent.selector); launcher.launch{value: 0.0005 ether}(p, NONE, 0); p = _input(); p.category = ""; vm.prank(alice); vm.expectRevert(SightlineLauncher.BadEvent.selector); launcher.launch{value: 0.0005 ether}(p, NONE, 0); p = _input(); p.latE6 = 90_000_001; vm.prank(alice); vm.expectRevert(SightlineLauncher.BadCoordinates.selector); launcher.launch{value: 0.0005 ether}(p, NONE, 0); } function test_salts_neverCollide() public { // Same creator twice, and two creators with identical inputs: all distinct salts. vm.prank(alice); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); vm.prank(alice); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); vm.prank(bob); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); assertEq(launcher.launchCount(), 3); assertEq(launcher.launchNonce(alice), 2); assertEq(launcher.launchNonce(bob), 1); } function test_refundFailure_reverts() public { RejectsEth r = new RejectsEth(); vm.deal(address(r), 1 ether); // Pre-fund the launcher so there's "leftover" to return; a creator that can't take it reverts. vm.deal(address(launcher), 1 wei); vm.prank(address(r)); vm.expectRevert(SightlineLauncher.RefundFailed.selector); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); } function test_getLaunches_pages() public { for (uint256 i = 0; i < 3; i++) { vm.prank(alice); launcher.launch{value: 0.0005 ether}(_input(), NONE, 0); } assertEq(launcher.getLaunches(0, 2).length, 2); assertEq(launcher.getLaunches(2, 5).length, 1); assertEq(launcher.getLaunches(3, 5).length, 0); vm.expectRevert(SightlineLauncher.UnknownToken.selector); launcher.getLaunch(address(0xBEEF)); } }