diff --git a/.github/workflows/fork-test.yml b/.github/workflows/fork-test.yml index 0198a02..f52fc4d 100644 --- a/.github/workflows/fork-test.yml +++ b/.github/workflows/fork-test.yml @@ -13,17 +13,11 @@ permissions: contents: read jobs: - fork-test: - name: Fork Tests + fork-test-baseline: + name: Fork Tests Baseline runs-on: ubuntu-latest timeout-minutes: 30 - # On push: only run when commit message contains [fork-test] - # On workflow_dispatch: always run - if: >- - github.event_name == 'workflow_dispatch' || - contains(github.event.head_commit.message, '[fork-test]') - # Advisory only — Citrea public RPC instability shouldn't block PRs. - continue-on-error: true + continue-on-error: false steps: - uses: actions/checkout@v4 @@ -37,16 +31,35 @@ jobs: - run: yarn install --frozen-lockfile - uses: foundry-rs/foundry-toolchain@v1 - - name: Fork tests (dev) + - name: Fork tests (prod) env: FOUNDRY_PROFILE: fork - FORK_ENV: dev - DEV_RPC_URL: ${{ secrets.DEV_RPC_URL }} - run: forge test -vvv + FORK_ENV: prod + PROD_RPC_URL: ${{ secrets.PROD_RPC_URL || 'https://rpc.mainnet.citrea.xyz' }} + run: forge test --match-path 'test/foundry/fork/**/*.sol' --no-match-path 'test/foundry/fork/live/**' -vvv - - name: Fork tests (prod) + fork-test-advisory-live: + name: Fork Tests Advisory Live + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: yarn.lock + - run: yarn install --frozen-lockfile + - uses: foundry-rs/foundry-toolchain@v1 + + - name: Advisory live fork tests (prod) env: FOUNDRY_PROFILE: fork FORK_ENV: prod - PROD_RPC_URL: ${{ secrets.PROD_RPC_URL }} - run: forge test -vvv + PROD_RPC_URL: ${{ secrets.PROD_RPC_URL || 'https://rpc.mainnet.citrea.xyz' }} + run: forge test --match-path 'test/foundry/fork/live/**' -vvv diff --git a/package.json b/package.json index 11a51d0..44a251e 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "test": "forge test", "snapshot": "forge snapshot --no-match-test 'testFuzz|invariant_'", "snapshot:check": "forge snapshot --no-match-test 'testFuzz|invariant_' --check --tolerance 15", - "test:fork:dev": "FOUNDRY_PROFILE=fork FORK_ENV=dev forge test -vvv", - "test:fork:prod": "FOUNDRY_PROFILE=fork FORK_ENV=prod forge test -vvv", + "test:fork:baseline": "FOUNDRY_PROFILE=fork FORK_ENV=prod forge test --match-path 'test/foundry/fork/**/*.sol' --no-match-path 'test/foundry/fork/live/**' -vvv", + "test:fork:advisory:prod": "FOUNDRY_PROFILE=fork FORK_ENV=prod forge test --match-path 'test/foundry/fork/live/**' -vvv", + "test:fork:dev": "FOUNDRY_PROFILE=fork FORK_ENV=dev forge test --match-path 'test/foundry/fork/**/*.sol' --no-match-path 'test/foundry/fork/live/**' -vvv", + "test:fork:prod": "yarn test:fork:baseline", "test:cov": "FOUNDRY_PROFILE=coverage forge coverage --report lcov", "lint": "tsc --noEmit --noUnusedLocals --noUnusedParameters", "format": "prettier --write \"contracts/**/*.sol\" \"scripts/**/*.ts\"", diff --git a/scripts/fork/build-redstone-submit-calldata.ts b/scripts/fork/build-redstone-submit-calldata.ts new file mode 100644 index 0000000..06a7dc1 --- /dev/null +++ b/scripts/fork/build-redstone-submit-calldata.ts @@ -0,0 +1,143 @@ +import { Contract, providers, utils } from 'ethers'; +import { WrapperBuilder } from '@redstone-finance/evm-connector'; +import { + getSignersForDataServiceId, + type DataServiceIds, +} from '@redstone-finance/sdk'; +import { loadEnvironment, normalizeEnvironment } from '../utils/environment'; +import type { Environment, EnvironmentFile } from '../types/environment'; + +const BUILD_PREFIX = '[build-redstone-submit-calldata]'; +const REDSTONE_DATA_SERVICE_ID: DataServiceIds = 'redstone-primary-prod'; +const REDSTONE_UNIQUE_SIGNERS = 3; +const CORE_ABI = [ + 'function submitSettlementSample(uint256)', + 'function redstoneFeedId() view returns (bytes32)', + 'function maxSampleDistance() view returns (uint64)', +] as const; + +type WrappedSubmitContract = Contract & { + populateTransaction: { + submitSettlementSample(marketId: string): Promise<{ data?: string }>; + }; +}; + +function log(message: string) { + console.error(`${BUILD_PREFIX} ${message}`); +} + +function fail(message: string): never { + throw new Error(message); +} + +function resolveRpcUrl(env: Environment): string { + const envVar = + env === 'dev' + ? 'DEV_RPC_URL' + : env === 'prod' + ? 'PROD_RPC_URL' + : 'LOCAL_RPC_URL'; + const value = process.env[envVar]; + if (!value) { + fail(`Missing ${envVar}`); + } + return value; +} + +function resolveCoreAddress( + passedCoreAddress: string | undefined, + envData: EnvironmentFile, +): string { + if (passedCoreAddress) { + return passedCoreAddress; + } + const fromEnvFile = envData.contracts.SignalsCoreProxy; + if (!fromEnvFile) { + fail('Missing SignalsCoreProxy in environment file'); + } + return fromEnvFile; +} + +function resolveFeedIdFallback(envData: EnvironmentFile): string { + const configuredFeedId = envData.config?.redstoneFeedId; + if (!configuredFeedId) { + fail('Missing redstoneFeedId in environment file'); + } + return configuredFeedId; +} + +async function resolveFeedId( + contract: Contract, + envData: EnvironmentFile, +): Promise { + try { + const onchainFeedId = (await contract.redstoneFeedId()) as string; + return utils.parseBytes32String(onchainFeedId); + } catch { + return resolveFeedIdFallback(envData); + } +} + +async function resolveMaxTimestampDeviationMs( + contract: Contract, + envData: EnvironmentFile, +): Promise { + try { + const onchainMaxDistance = (await contract.maxSampleDistance()) as { + toNumber(): number; + }; + return onchainMaxDistance.toNumber() * 1000; + } catch { + const configured = envData.config?.redstoneMaxSampleDistance; + if (!configured) return undefined; + const parsed = Number(configured); + return Number.isFinite(parsed) ? parsed * 1000 : undefined; + } +} + +async function main() { + const [envArg, coreAddressArg, marketIdArg] = process.argv.slice(2); + if (!envArg || !marketIdArg) { + fail( + 'Usage: tsx scripts/fork/build-redstone-submit-calldata.ts ', + ); + } + if (!/^\d+$/.test(marketIdArg)) { + fail(`Invalid market ID: ${marketIdArg}`); + } + + const env = normalizeEnvironment(envArg) as Environment; + const envData = loadEnvironment(env); + const coreAddress = resolveCoreAddress(coreAddressArg, envData); + const provider = new providers.JsonRpcProvider(resolveRpcUrl(env)); + const core = new Contract(coreAddress, CORE_ABI, provider); + + const feedId = await resolveFeedId(core, envData); + const maxTimestampDeviationMS = await resolveMaxTimestampDeviationMs( + core, + envData, + ); + + const wrapped = WrapperBuilder.wrap(core).usingDataService({ + dataServiceId: REDSTONE_DATA_SERVICE_ID, + dataPackagesIds: [feedId], + uniqueSignersCount: REDSTONE_UNIQUE_SIGNERS, + authorizedSigners: getSignersForDataServiceId(REDSTONE_DATA_SERVICE_ID), + ...(maxTimestampDeviationMS ? { maxTimestampDeviationMS } : {}), + }) as WrappedSubmitContract; + + const tx = + await wrapped.populateTransaction.submitSettlementSample(marketIdArg); + if (!tx.data || !tx.data.startsWith('0x')) { + fail('Failed to build calldata'); + } + + process.stdout.write(`${tx.data}\n`); +} + +main().catch((error: unknown) => { + const message = + error instanceof Error ? error.message : 'Unknown error building calldata'; + log(message); + process.exit(1); +}); diff --git a/test/foundry/fork/AdminLifecycleFork.t.sol b/test/foundry/fork/AdminLifecycleFork.t.sol new file mode 100644 index 0000000..8779d9f --- /dev/null +++ b/test/foundry/fork/AdminLifecycleFork.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./base/ForkProtocolTest.sol"; +import "../../../contracts/errors/SignalsErrors.sol"; + +/// @title AdminLifecycleForkTest +/// @notice Deterministic fork coverage for admin controls, pause semantics, and capital-stack flows. +contract AdminLifecycleForkTest is ForkProtocolTest { + function test_risk_config_can_gate_market_creation_without_touching_position_exposure_logic() public { + vm.prank(ownerSafe); + core.setRiskConfig(1, WAD, true); + + (uint256 lambda, uint256 kDrawdown, bool enforceAlpha) = core.getRiskConfig(); + assertEq(lambda, 1, "lambda not updated"); + assertEq(kDrawdown, WAD, "kDrawdown not updated"); + assertTrue(enforceAlpha, "alpha enforcement not enabled"); + + uint64 targetBatch = _targetBatchAfter(uint64(block.timestamp + 120)); + uint64 settlementTimestamp = _batchStartTimestamp(targetBatch) + 600; + if (settlementTimestamp < block.timestamp + 120) { + settlementTimestamp = uint64(block.timestamp + 120); + targetBatch = _toBatchId(settlementTimestamp); + } + + uint64 endTimestamp = settlementTimestamp - 30; + uint64 startTimestamp = endTimestamp - 30; + address seedData = _deployUniformSeedData(4); + + vm.prank(ownerSafe); + vm.expectPartialRevert(SignalsErrors.AlphaExceedsLimit.selector); + core.createMarket( + 0, + 4, + 1, + startTimestamp, + endTimestamp, + settlementTimestamp, + 4, + WAD, + _defaultFeePolicy(), + seedData + ); + + vm.prank(ownerSafe); + core.setRiskConfig(0.3e18, WAD, false); + + (lambda, kDrawdown, enforceAlpha) = core.getRiskConfig(); + assertEq(lambda, 0.3e18, "lambda reset mismatch"); + assertEq(kDrawdown, WAD, "kDrawdown reset mismatch"); + assertFalse(enforceAlpha, "alpha enforcement still enabled"); + + vm.prank(ownerSafe); + uint256 marketId = core.createMarket( + 0, + 4, + 1, + startTimestamp, + endTimestamp, + settlementTimestamp, + 4, + WAD, + _defaultFeePolicy(), + seedData + ); + + vm.prank(ownerSafe); + core.seedNextChunks(marketId, 4); + assertTrue(core.getMarket(marketId).isSeeded, "market did not seed after risk config update"); + } + + function test_operator_paths_pause_owner_fallback_and_claim_invariant_hold_on_fork() public { + address operator = _ensureForkOperator(); + + uint64 targetBatch = _targetBatchAfter(uint64(block.timestamp + 120)); + uint64 settlementTimestamp = _batchStartTimestamp(targetBatch) + 600; + if (settlementTimestamp < block.timestamp + 120) { + settlementTimestamp = uint64(block.timestamp + 120); + targetBatch = _toBatchId(settlementTimestamp); + } + + address seedData = _deployUniformSeedData(4); + uint64 endTimestamp = settlementTimestamp - 30; + uint64 startTimestamp = endTimestamp - 30; + + vm.prank(operator); + uint256 marketId = core.createMarket( + 0, + 4, + 1, + startTimestamp, + endTimestamp, + settlementTimestamp, + 4, + WAD, + _defaultFeePolicy(), + seedData + ); + + vm.prank(operator); + core.seedNextChunks(marketId, 2); + assertFalse(core.getMarket(marketId).isSeeded, "market seeded too early"); + + vm.prank(operator); + core.seedNextChunks(marketId, 2); + assertTrue(core.getMarket(marketId).isSeeded, "operator could not finish seeding"); + + vm.prank(ownerSafe); + core.updateMarketTiming(marketId, startTimestamp + 1, endTimestamp + 1, settlementTimestamp + 1); + assertEq(core.getMarket(marketId).settlementTimestamp, settlementTimestamp + 1, "owner timing update failed"); + + address lp = makeAddr("pausedLp"); + _fundAndApprove(lp, 1_000_000_000); + + vm.prank(lp); + uint64 depositRequestId = core.requestDeposit(300_000_000); + + uint64 depositBatch = core.getCurrentBatchId() + 1; + _processBatchesThrough(depositBatch); + + vm.prank(lp); + uint256 mintedShares = core.claimDeposit(depositRequestId); + + uint256 withdrawShares = mintedShares / 2; + vm.prank(lp); + uint64 withdrawRequestId = core.requestWithdraw(withdrawShares); + + uint64 withdrawBatch = core.getCurrentBatchId() + core.getWithdrawalLagBatches() + 1; + _processBatchesThrough(withdrawBatch); + + vm.prank(operator); + core.pause(); + assertTrue(core.paused(), "operator pause failed"); + + vm.prank(lp); + vm.expectRevert(); + core.requestDeposit(10_000_000); + + uint64 nextBatch = core.getCurrentBatchId() + 1; + uint64 batchEnd = _batchEndTimestamp(nextBatch); + if (block.timestamp <= batchEnd) { + vm.warp(uint256(batchEnd) + 1); + } + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(SignalsErrors.UnauthorizedCaller.selector, operator)); + core.processDailyBatch(nextBatch); + + vm.prank(ownerSafe); + core.processDailyBatch(nextBatch); + assertEq(core.getCurrentBatchId(), nextBatch, "owner could not process while paused"); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(SignalsErrors.UnauthorizedCaller.selector, operator)); + core.markSettlementFailed(999_999); + + vm.prank(ownerSafe); + vm.expectRevert(abi.encodeWithSelector(SignalsErrors.MarketNotFound.selector, uint256(999_999))); + core.markSettlementFailed(999_999); + + uint256 balanceBeforeClaim = payment.balanceOf(lp); + vm.prank(lp); + core.claimWithdraw(withdrawRequestId); + assertGt(payment.balanceOf(lp), balanceBeforeClaim, "claimWithdraw blocked while paused"); + + vm.prank(ownerSafe); + core.unpause(); + assertFalse(core.paused(), "owner unpause failed"); + } + + function test_fee_waterfall_processing_updates_backstop_and_treasury_after_owner_config_change() public { + vm.prank(ownerSafe); + core.setFeeWaterfallConfig(0, 0, 0.5e18, 0.5e18); + + (uint256 backstopBefore, uint256 treasuryBefore) = core.getCapitalStack(); + + uint64 targetBatch = _targetBatchAfter(uint64(block.timestamp + 120)); + uint256 marketId = _createUniformMarketForBatch(targetBatch); + _warpIntoTradingWindow(marketId); + + address trader = makeAddr("waterfallTrader"); + _fundAndApprove(trader, 100_000_000); + + uint128 losingQty = 4_000_000; + uint256 maxCost = core.calculateOpenCost(marketId, 1, 2, losingQty) + 1_000_000; + + vm.prank(trader); + core.openPosition(marketId, 1, 2, losingQty, maxCost); + + uint64 batchId = _secondarySettleAndSnapshot(marketId, 0, 8); + _processBatchesThrough(batchId); + + (, uint256 ftot, , , , , bool processed) = core.getDailyPnl(batchId); + (uint256 backstopAfter, uint256 treasuryAfter) = core.getCapitalStack(); + + assertTrue(processed, "daily pnl not processed"); + assertGt(ftot, 0, "trade generated no fees"); + assertGt(backstopAfter, backstopBefore, "backstop did not receive fee share"); + assertGt(treasuryAfter, treasuryBefore, "treasury did not receive fee share"); + } + + function test_permissionless_funding_and_owner_withdrawals_adjust_capital_stack() public { + address funder = makeAddr("capitalFunder"); + _fundAndApprove(funder, 100_000_000); + + (uint256 backstopBefore, uint256 treasuryBefore) = core.getCapitalStack(); + + vm.prank(funder); + core.fundBackstop(20_000_000); + vm.prank(funder); + core.fundTreasury(15_000_000); + + (uint256 backstopFunded, uint256 treasuryFunded) = core.getCapitalStack(); + assertEq(backstopFunded - backstopBefore, 20_000_000 * 1e12, "backstop funding mismatch"); + assertEq(treasuryFunded - treasuryBefore, 15_000_000 * 1e12, "treasury funding mismatch"); + + uint256 ownerBalanceBefore = payment.balanceOf(ownerSafe); + + vm.prank(ownerSafe); + core.withdrawBackstop(5_000_000); + vm.prank(ownerSafe); + core.withdrawTreasury(7_000_000); + + (uint256 backstopAfter, uint256 treasuryAfter) = core.getCapitalStack(); + assertEq(backstopFunded - backstopAfter, 5_000_000 * 1e12, "backstop withdrawal mismatch"); + assertEq(treasuryFunded - treasuryAfter, 7_000_000 * 1e12, "treasury withdrawal mismatch"); + assertEq(payment.balanceOf(ownerSafe) - ownerBalanceBefore, 12_000_000, "owner did not receive withdrawals"); + } +} diff --git a/test/foundry/fork/CoreTradeFork.t.sol b/test/foundry/fork/CoreTradeFork.t.sol new file mode 100644 index 0000000..cf4f116 --- /dev/null +++ b/test/foundry/fork/CoreTradeFork.t.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./base/ForkProtocolTest.sol"; + +/// @title CoreTradeForkTest +/// @notice Deterministic fork coverage for created-market payout flows. +contract CoreTradeForkTest is ForkProtocolTest { + uint128 internal constant QUANTITY = 5_000_000; + + function test_sponsored_position_claim_splits_principal_and_profit_after_batch_processing() public { + uint64 targetBatch = _targetBatchAfter(uint64(block.timestamp + 120)); + uint256 marketId = _createUniformMarketForBatch(targetBatch); + _warpIntoTradingWindow(marketId); + + address sponsor = makeAddr("sponsor"); + address beneficiary = makeAddr("beneficiary"); + _fundAndApprove(sponsor, 100_000_000); + + uint256 maxCost = core.calculateOpenCost(marketId, 1, 3, QUANTITY) + 1_000_000; + uint256 positionId = position.nextId(); + + vm.prank(sponsor); + core.openPositionFor(beneficiary, marketId, 1, 3, QUANTITY, maxCost); + + uint256 sponsoredCost = core.getSponsoredCost(positionId); + assertGt(sponsoredCost, 0, "sponsored cost not recorded"); + assertEq(core.getSponsorAddress(positionId), sponsor, "sponsor address mismatch"); + + uint64 batchId = _secondarySettleAndSnapshot(marketId, 2_000_000, 8); + _processBatchesThrough(batchId); + + uint64 claimOpen = core.getMarket(marketId).settlementTimestamp + core.claimDelaySeconds(); + vm.warp(uint256(claimOpen) + 1); + + uint256 sponsorBefore = payment.balanceOf(sponsor); + uint256 beneficiaryBefore = payment.balanceOf(beneficiary); + + vm.prank(beneficiary); + core.claimPayout(positionId); + + uint256 sponsorReceived = payment.balanceOf(sponsor) - sponsorBefore; + uint256 beneficiaryReceived = payment.balanceOf(beneficiary) - beneficiaryBefore; + + assertFalse(position.exists(positionId), "position still exists"); + assertEq(sponsorReceived + beneficiaryReceived, QUANTITY, "total payout mismatch"); + assertEq(sponsorReceived, sponsoredCost, "sponsor principal mismatch"); + assertEq(beneficiaryReceived, QUANTITY - sponsoredCost, "beneficiary profit mismatch"); + assertEq(core.getCurrentBatchId(), batchId, "batch did not process"); + } + + function test_batch_claim_handles_winner_loser_mix_after_batch_processing() public { + uint64 targetBatch = _targetBatchAfter(uint64(block.timestamp + 120)); + uint256 marketId = _createUniformMarketForBatch(targetBatch); + _warpIntoTradingWindow(marketId); + + address trader = makeAddr("batchTrader"); + _fundAndApprove(trader, 100_000_000); + + uint128 winnerQty = 2_000_000; + uint128 loserQty = 3_000_000; + + uint256 winnerMaxCost = core.calculateOpenCost(marketId, 0, 2, winnerQty) + 1_000_000; + uint256 loserMaxCost = core.calculateOpenCost(marketId, 2, 4, loserQty) + 1_000_000; + + uint256 winnerId = position.nextId(); + vm.prank(trader); + core.openPosition(marketId, 0, 2, winnerQty, winnerMaxCost); + + uint256 loserId = position.nextId(); + vm.prank(trader); + core.openPosition(marketId, 2, 4, loserQty, loserMaxCost); + + uint64 batchId = _secondarySettleAndSnapshot(marketId, 1_000_000, 8); + _processBatchesThrough(batchId); + + uint64 claimOpen = core.getMarket(marketId).settlementTimestamp + core.claimDelaySeconds(); + vm.warp(uint256(claimOpen) + 1); + + uint256 balanceBefore = payment.balanceOf(trader); + uint256[] memory positionIds = new uint256[](2); + positionIds[0] = winnerId; + positionIds[1] = loserId; + + vm.prank(trader); + core.batchClaimPayout(positionIds); + + assertEq(payment.balanceOf(trader) - balanceBefore, winnerQty, "winner payout mismatch"); + assertFalse(position.exists(winnerId), "winner position still exists"); + assertFalse(position.exists(loserId), "loser position still exists"); + assertEq(core.getCurrentBatchId(), batchId, "batch did not process"); + } +} diff --git a/test/foundry/fork/VaultBatchFork.t.sol b/test/foundry/fork/VaultBatchFork.t.sol new file mode 100644 index 0000000..511aeec --- /dev/null +++ b/test/foundry/fork/VaultBatchFork.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./base/ForkProtocolTest.sol"; + +/// @title VaultBatchForkTest +/// @notice Deterministic fork coverage for vault request, cancel, claim, and batch flows. +contract VaultBatchForkTest is ForkProtocolTest { + function test_deposit_and_withdraw_claim_lifecycle_processes_real_batches() public { + address lp = makeAddr("lp"); + _fundAndApprove(lp, 1_000_000_000); + + uint256 depositAmount = 300_000_000; + uint256 balanceBeforeDeposit = payment.balanceOf(lp); + + vm.prank(lp); + uint64 depositRequestId = core.requestDeposit(depositAmount); + + assertEq(payment.balanceOf(lp), balanceBeforeDeposit - depositAmount, "deposit escrow mismatch"); + + uint64 depositBatch = core.getCurrentBatchId() + 1; + _processBatchesThrough(depositBatch); + + vm.prank(lp); + uint256 mintedShares = core.claimDeposit(depositRequestId); + + assertGt(mintedShares, 0, "deposit minted no shares"); + assertEq(lpShare.balanceOf(lp), mintedShares, "lp share balance mismatch"); + + uint256 withdrawShares = mintedShares / 2; + uint256 balanceBeforeWithdrawClaim = payment.balanceOf(lp); + + vm.prank(lp); + uint64 withdrawRequestId = core.requestWithdraw(withdrawShares); + + assertEq(lpShare.balanceOf(lp), mintedShares - withdrawShares, "withdraw burn mismatch"); + + uint64 withdrawBatch = core.getCurrentBatchId() + core.getWithdrawalLagBatches() + 1; + _processBatchesThrough(withdrawBatch); + + vm.prank(lp); + uint256 withdrawnAssets = core.claimWithdraw(withdrawRequestId); + + assertGt(withdrawnAssets, 0, "withdraw claim returned zero"); + assertGt(payment.balanceOf(lp), balanceBeforeWithdrawClaim, "withdraw claim paid nothing"); + assertEq(lpShare.balanceOf(lp), mintedShares - withdrawShares, "lp shares changed after claim"); + } + + function test_deposit_and_withdraw_requests_can_be_cancelled_before_processing() public { + address lp = makeAddr("cancelLp"); + _fundAndApprove(lp, 1_000_000_000); + + uint256 cancelDepositAmount = 250_000_000; + uint256 balanceBeforeDeposit = payment.balanceOf(lp); + + vm.startPrank(lp); + uint64 depositRequestId = core.requestDeposit(cancelDepositAmount); + core.cancelDeposit(depositRequestId); + vm.stopPrank(); + + assertEq(payment.balanceOf(lp), balanceBeforeDeposit, "deposit cancel did not refund"); + + vm.prank(lp); + uint64 claimableDepositId = core.requestDeposit(300_000_000); + + uint64 depositBatch = core.getCurrentBatchId() + 1; + _processBatchesThrough(depositBatch); + + vm.prank(lp); + uint256 mintedShares = core.claimDeposit(claimableDepositId); + + vm.prank(lp); + uint64 withdrawRequestId = core.requestWithdraw(mintedShares / 2); + + uint256 lpSharesAfterBurn = lpShare.balanceOf(lp); + vm.prank(lp); + core.cancelWithdraw(withdrawRequestId); + + assertEq( + lpShare.balanceOf(lp), + lpSharesAfterBurn + (mintedShares / 2), + "withdraw cancel did not restore shares" + ); + } +} diff --git a/test/foundry/fork/base/ForkBaseTest.sol b/test/foundry/fork/base/ForkBaseTest.sol index 3f816d3..add7a87 100644 --- a/test/foundry/fork/base/ForkBaseTest.sol +++ b/test/foundry/fork/base/ForkBaseTest.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import "forge-std/Test.sol"; import "../../../../contracts/core/SignalsCore.sol"; +import "../../../../contracts/interfaces/ISignalsCore.sol"; import "../../../../contracts/position/SignalsPosition.sol"; import "../../../../contracts/tokens/SignalsLPShare.sol"; @@ -12,10 +13,6 @@ import "../../../../contracts/tokens/SignalsLPShare.sol"; abstract contract ForkBaseTest is Test { bytes32 internal constant ERC1967_IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; - /// @dev Hardcoded selector for markets(uint256) — deployed proxy still exposes - /// the auto getter. Switch to core.getMarket() after on-chain upgrade. - bytes4 private constant MARKETS_SELECTOR = bytes4(keccak256("markets(uint256)")); - string internal envName; string internal envJsonPath; @@ -91,7 +88,7 @@ abstract contract ForkBaseTest is Test { return addr; } - // --- Market snapshot helpers (avoids stack-too-deep with 24-field tuple) --- + // --- Market snapshot helpers --- struct MarketSnapshot { bool isSeeded; @@ -103,38 +100,14 @@ abstract contract ForkBaseTest is Test { uint256 initialRootSum; } - /// @dev Reads market data via staticcall and decodes only the fields we need, - /// avoiding stack-too-deep from destructuring the 24-field Market tuple. function _readMarket(uint256 marketId) internal view returns (MarketSnapshot memory snap) { - (bool success, bytes memory data) = address(core).staticcall( - abi.encodeWithSelector(MARKETS_SELECTOR, marketId) - ); - require(success, "markets() staticcall failed"); - - // Market struct ABI word indices (each 32 bytes): - // 0:isSeeded, 1:settled, 2:snapshotChunksDone, 3:failed, - // 4:numBins, 5:openPositionCount, 6:snapshotChunkCursor, 7:seedCursor, - // 8:startTimestamp, 9:endTimestamp, 10:settlementTimestamp, 11:settlementFinalizedAt, - // 12:minTick, 13:maxTick, 14:tickSpacing, 15:settlementTick, 16:settlementValue, - // 17:liquidityParameter, 18:feePolicy, 19:seedData, 20:initialRootSum, - // 21:accumulatedFees, 22:minFactor, 23:deltaEt - snap.isSeeded = _decodeWordBool(data, 0); - snap.settled = _decodeWordBool(data, 1); - snap.numBins = uint32(_decodeWord(data, 4)); - snap.startTs = uint64(_decodeWord(data, 8)); - snap.endTs = uint64(_decodeWord(data, 9)); - snap.liquidityParameter = _decodeWord(data, 17); - snap.initialRootSum = _decodeWord(data, 20); - } - - function _decodeWord(bytes memory data, uint256 wordIndex) internal pure returns (uint256 value) { - uint256 offset = wordIndex * 32; - assembly { - value := mload(add(add(data, 32), offset)) - } - } - - function _decodeWordBool(bytes memory data, uint256 wordIndex) internal pure returns (bool) { - return _decodeWord(data, wordIndex) != 0; + ISignalsCore.Market memory market = core.getMarket(marketId); + snap.isSeeded = market.isSeeded; + snap.settled = market.settled; + snap.numBins = market.numBins; + snap.startTs = market.startTimestamp; + snap.endTs = market.endTimestamp; + snap.liquidityParameter = market.liquidityParameter; + snap.initialRootSum = market.initialRootSum; } } diff --git a/test/foundry/fork/base/ForkLiveMarketTest.sol b/test/foundry/fork/base/ForkLiveMarketTest.sol new file mode 100644 index 0000000..47c8243 --- /dev/null +++ b/test/foundry/fork/base/ForkLiveMarketTest.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./ForkBaseTest.sol"; +import "../../../../contracts/interfaces/ISignalsCore.sol"; + +/// @title ForkLiveMarketTest +/// @notice Shared helpers for advisory fork suites that depend on an ambient live market. +abstract contract ForkLiveMarketTest is ForkBaseTest { + function _tryFindActiveMarket() + internal + view + returns (bool found, uint256 marketId, ISignalsCore.Market memory market) + { + uint256 upper = core.nextMarketId() + 5; + for (uint256 i = upper; i > 0; --i) { + try core.getMarket(i) returns (ISignalsCore.Market memory candidate) { + if ( + candidate.isSeeded && + !candidate.settled && + block.timestamp >= candidate.startTimestamp && + block.timestamp <= candidate.endTimestamp + ) { + return (true, i, candidate); + } + } catch { + continue; + } + } + } +} diff --git a/test/foundry/fork/base/ForkProtocolTest.sol b/test/foundry/fork/base/ForkProtocolTest.sol new file mode 100644 index 0000000..436010d --- /dev/null +++ b/test/foundry/fork/base/ForkProtocolTest.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./ForkBaseTest.sol"; +import "../../base/SeedHelper.sol"; + +/// @title ForkProtocolTest +/// @notice Deterministic helper layer for fork tests that create their own markets and batches. +abstract contract ForkProtocolTest is ForkBaseTest { + uint256 internal constant WAD = 1e18; + uint64 internal constant BATCH_SECONDS = 86_400; + uint64 internal constant BATCH_TIMEZONE_OFFSET = 28_800; + + IERC20 internal payment; + address internal forkOperator; + + function setUp() public virtual override { + super.setUp(); + payment = IERC20(paymentToken); + } + + function _fundAndApprove(address user, uint256 amount6) internal { + deal(paymentToken, user, amount6); + vm.prank(user); + payment.approve(address(core), type(uint256).max); + } + + function _ensureForkOperator() internal returns (address operator) { + if (forkOperator == address(0)) { + forkOperator = makeAddr("forkOperator"); + vm.prank(ownerSafe); + core.setOperator(forkOperator, true); + } + return forkOperator; + } + + function _toBatchId(uint64 timestamp) internal pure returns (uint64) { + if (timestamp < BATCH_TIMEZONE_OFFSET) { + return 0; + } + return (timestamp - BATCH_TIMEZONE_OFFSET) / BATCH_SECONDS; + } + + function _batchStartTimestamp(uint64 batchId) internal pure returns (uint64) { + return batchId * BATCH_SECONDS + BATCH_TIMEZONE_OFFSET; + } + + function _batchEndTimestamp(uint64 batchId) internal pure returns (uint64) { + return _batchStartTimestamp(batchId + 1); + } + + function _targetBatchAfter(uint64 minTimestamp) internal view returns (uint64 targetBatch) { + uint64 nextBatch = core.getCurrentBatchId() + 1; + uint64 futureBatch = _toBatchId(minTimestamp); + return futureBatch > nextBatch ? futureBatch : nextBatch; + } + + function _warpIntoTradingWindow(uint256 marketId) internal { + ISignalsCore.Market memory market = core.getMarket(marketId); + if (block.timestamp < market.startTimestamp) { + vm.warp(uint256(market.startTimestamp) + 1); + } + } + + function _defaultFeePolicy() internal view returns (address) { + address feePolicy = _tryContractAddr("FeePolicy10bps"); + if (feePolicy == address(0)) { + feePolicy = _contractAddr("FeePolicyNull"); + } + return feePolicy; + } + + function _deployUniformSeedData(uint32 numBins) internal returns (address) { + uint256[] memory factors = new uint256[](numBins); + for (uint32 i = 0; i < numBins; i++) { + factors[i] = WAD; + } + return address(SeedHelper.deploySeedData(factors)); + } + + function _deployConcentratedSeedData(uint32 numBins, uint256 edgeFactor) internal returns (address) { + uint256[] memory factors = new uint256[](numBins); + for (uint32 i = 0; i < numBins; i++) { + factors[i] = WAD; + } + factors[0] = edgeFactor; + return address(SeedHelper.deploySeedData(factors)); + } + + function _createUniformMarketForBatch(uint64 batchId) internal returns (uint256 marketId) { + uint64 minSettle = uint64(block.timestamp + 120); + uint64 batchStart = _batchStartTimestamp(batchId); + uint64 settlementTimestamp = minSettle > batchStart + 600 ? minSettle : batchStart + 600; + require(_toBatchId(settlementTimestamp) == batchId, "fork batch mismatch"); + + uint64 endTimestamp = settlementTimestamp - 30; + uint64 startTimestamp = endTimestamp - 30; + if (startTimestamp >= endTimestamp) { + startTimestamp = endTimestamp - 1; + } + + address seedData = _deployUniformSeedData(4); + vm.prank(ownerSafe); + marketId = core.createMarket( + 0, + 4, + 1, + startTimestamp, + endTimestamp, + settlementTimestamp, + 4, + WAD, + _defaultFeePolicy(), + seedData + ); + vm.prank(ownerSafe); + core.seedNextChunks(marketId, 4); + } + + function _createConcentratedMarketForBatch( + uint64 batchId, + uint32 numBins, + uint256 alpha, + uint256 edgeFactor + ) internal returns (uint256 marketId) { + uint64 minSettle = uint64(block.timestamp + 120); + uint64 batchStart = _batchStartTimestamp(batchId); + uint64 settlementTimestamp = minSettle > batchStart + 600 ? minSettle : batchStart + 600; + require(_toBatchId(settlementTimestamp) == batchId, "fork batch mismatch"); + + uint64 endTimestamp = settlementTimestamp - 30; + uint64 startTimestamp = endTimestamp - 30; + if (startTimestamp >= endTimestamp) { + startTimestamp = endTimestamp - 1; + } + + address seedData = _deployConcentratedSeedData(numBins, edgeFactor); + vm.prank(ownerSafe); + marketId = core.createMarket( + 0, + int256(uint256(numBins)), + 1, + startTimestamp, + endTimestamp, + settlementTimestamp, + numBins, + alpha, + _defaultFeePolicy(), + seedData + ); + vm.prank(ownerSafe); + core.seedNextChunks(marketId, numBins); + } + + function _secondarySettleAndSnapshot( + uint256 marketId, + int256 settlementValue, + uint32 maxChunksPerTx + ) internal returns (uint64 batchId) { + ISignalsCore.Market memory market = core.getMarket(marketId); + vm.warp(uint256(market.settlementTimestamp) + core.settlementSubmitWindow() + 1); + + vm.startPrank(ownerSafe); + core.markSettlementFailed(marketId); + core.finalizeSecondarySettlement(marketId, settlementValue); + core.requestSettlementChunks(marketId, maxChunksPerTx); + vm.stopPrank(); + + return _toBatchId(market.settlementTimestamp); + } + + function _fallbackSettlementValue(ISignalsCore.Market memory market) internal pure returns (int256) { + int256 midpointTick = market.minTick + (int256(uint256(market.numBins / 2)) * market.tickSpacing); + int256 lastValidTick = market.maxTick - market.tickSpacing; + if (midpointTick > lastValidTick) { + midpointTick = lastValidTick; + } + return midpointTick * 1_000_000; + } + + function _resolveAmbientBatchMarkets(uint64 batchId) internal { + (uint64 total, uint64 resolved) = core.getBatchMarketState(batchId); + if (total == resolved) { + return; + } + + uint256 maxMarketId = core.nextMarketId(); + for (uint256 marketId = 1; marketId <= maxMarketId && resolved < total; marketId++) { + ISignalsCore.Market memory market = core.getMarket(marketId); + if (market.settled || _toBatchId(market.settlementTimestamp) != batchId) { + continue; + } + + uint64 opsStart = market.settlementTimestamp + core.settlementSubmitWindow(); + if (block.timestamp < opsStart) { + vm.warp(uint256(opsStart) + 1); + } + + vm.startPrank(ownerSafe); + try core.finalizePrimarySettlement(marketId) {} catch { + core.markSettlementFailed(marketId); + core.finalizeSecondarySettlement(marketId, _fallbackSettlementValue(market)); + } + vm.stopPrank(); + + (total, resolved) = core.getBatchMarketState(batchId); + } + + require(total == resolved, "fork baseline blocked by unresolved batch"); + } + + function _processBatchesThrough(uint64 targetBatch) internal { + while (core.getCurrentBatchId() < targetBatch) { + uint64 nextBatch = core.getCurrentBatchId() + 1; + _resolveAmbientBatchMarkets(nextBatch); + + uint64 batchEnd = _batchEndTimestamp(nextBatch); + if (block.timestamp <= batchEnd) { + vm.warp(uint256(batchEnd) + 1); + } + + vm.prank(ownerSafe); + core.processDailyBatch(nextBatch); + } + } +} diff --git a/test/foundry/fork/base/ForkRedstoneFFI.sol b/test/foundry/fork/base/ForkRedstoneFFI.sol new file mode 100644 index 0000000..fcd0186 --- /dev/null +++ b/test/foundry/fork/base/ForkRedstoneFFI.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./ForkBaseTest.sol"; + +/// @title ForkRedstoneFFI +/// @notice Minimal FFI bridge for advisory primary-settlement fork tests. +abstract contract ForkRedstoneFFI is ForkBaseTest { + function _tryBuildPrimarySettlementCalldata( + uint256 marketId + ) internal returns (bool ok, bytes memory fullCalldata) { + try this.__buildPrimarySettlementCalldata(marketId) returns (bytes memory data) { + return (true, data); + } catch { + return (false, bytes("")); + } + } + + function _trySubmitPrimarySettlementSample(uint256 marketId) internal returns (bool ok) { + bytes memory fullCalldata; + (ok, fullCalldata) = _tryBuildPrimarySettlementCalldata(marketId); + if (!ok) { + return false; + } + + (ok, ) = address(core).call(fullCalldata); + } + + function __buildPrimarySettlementCalldata(uint256 marketId) external returns (bytes memory fullCalldata) { + require(msg.sender == address(this), "ForkRedstoneFFI: self only"); + + string[] memory inputs = new string[](5); + inputs[0] = "./node_modules/.bin/tsx"; + inputs[1] = "scripts/fork/build-redstone-submit-calldata.ts"; + inputs[2] = envName; + inputs[3] = vm.toString(address(core)); + inputs[4] = vm.toString(marketId); + + bytes memory stdout = vm.ffi(inputs); + return vm.parseBytes(string(stdout)); + } +} diff --git a/test/foundry/fork/live/DirectCoreFork.t.sol b/test/foundry/fork/live/DirectCoreFork.t.sol new file mode 100644 index 0000000..6626264 --- /dev/null +++ b/test/foundry/fork/live/DirectCoreFork.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "../base/ForkLiveMarketTest.sol"; +import "../../../../contracts/interfaces/ISignalsCore.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title DirectCoreForkTest +/// @notice Advisory live-market coverage for direct SignalsCore trading without Router custody. +contract DirectCoreForkTest is ForkLiveMarketTest { + IERC20 internal ctUSDToken; + + function setUp() public override { + super.setUp(); + ctUSDToken = IERC20(paymentToken); + } + + function test_direct_trade_lifecycle_on_active_market() public { + (bool found, uint256 marketId, ISignalsCore.Market memory market) = _tryFindActiveMarket(); + if (!found) return; + + address trader = makeAddr("directCoreTrader"); + deal(paymentToken, trader, 100_000_000); + + vm.prank(trader); + ctUSDToken.approve(address(core), type(uint256).max); + + int256 centerTick = ((market.minTick + market.maxTick) / 2 / market.tickSpacing) * market.tickSpacing; + uint128 openQty = 5_000_000; + uint256 positionId = position.nextId(); + uint256 openCost = core.calculateOpenCost(marketId, centerTick, centerTick + market.tickSpacing, openQty); + + vm.prank(trader); + core.openPosition(marketId, centerTick, centerTick + market.tickSpacing, openQty, openCost + 1_000_000); + + uint128 increaseQty = 2_000_000; + uint256 increaseCost = core.calculateIncreaseCost(positionId, increaseQty); + + vm.prank(trader); + core.increasePosition(positionId, increaseQty, increaseCost + 1_000_000); + assertEq(position.ownerOf(positionId), trader, "owner changed after increase"); + + uint128 quantityAfterIncrease = position.getPosition(positionId).quantity; + uint256 proceedsBeforeDecrease = ctUSDToken.balanceOf(trader); + + vm.prank(trader); + core.decreasePosition(positionId, quantityAfterIncrease / 2, 0); + + assertGt(ctUSDToken.balanceOf(trader), proceedsBeforeDecrease, "decrease paid nothing"); + assertTrue(position.exists(positionId), "position burned on partial decrease"); + + vm.prank(trader); + core.closePosition(positionId, 0); + + assertFalse(position.exists(positionId), "position still exists after close"); + } +} diff --git a/test/foundry/fork/OperatorUpgradeFork.t.sol b/test/foundry/fork/live/OperatorUpgradeFork.t.sol similarity index 86% rename from test/foundry/fork/OperatorUpgradeFork.t.sol rename to test/foundry/fork/live/OperatorUpgradeFork.t.sol index 2b838be..706ee13 100644 --- a/test/foundry/fork/OperatorUpgradeFork.t.sol +++ b/test/foundry/fork/live/OperatorUpgradeFork.t.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import "./base/ForkBaseTest.sol"; -import "../../../contracts/core/SignalsCore.sol"; -import "../../../contracts/modules/TradeModule.sol"; -import "../../../contracts/router/SignalsRouter.sol"; -import "../../../contracts/interfaces/IAlgebraSwapRouter.sol"; -import "../../../contracts/interfaces/ISignalsCore.sol"; -import "../../../contracts/interfaces/ISignalsPosition.sol"; +import "../base/ForkLiveMarketTest.sol"; +import "../../../../contracts/core/SignalsCore.sol"; +import "../../../../contracts/modules/TradeModule.sol"; +import "../../../../contracts/router/SignalsRouter.sol"; +import "../../../../contracts/interfaces/IAlgebraSwapRouter.sol"; +import "../../../../contracts/interfaces/ISignalsCore.sol"; +import "../../../../contracts/interfaces/ISignalsPosition.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; @@ -17,8 +17,8 @@ import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /// works against real on-chain state with real Satsuma swaps. /// /// Run with: -/// PROD_RPC_URL=... FOUNDRY_PROFILE=fork FORK_ENV=prod forge test --match-contract OperatorUpgradeForkTest -vv -contract OperatorUpgradeForkTest is ForkBaseTest { +/// PROD_RPC_URL=... FOUNDRY_PROFILE=fork FORK_ENV=prod forge test --match-contract OperatorUpgradeForkTest --match-path 'test/foundry/fork/live/**' -vv +contract OperatorUpgradeForkTest is ForkLiveMarketTest { IAlgebraSwapRouter internal liveSwapRouter; IERC20 internal usdcE; IERC20 internal ctUSDToken; @@ -86,7 +86,8 @@ contract OperatorUpgradeForkTest is ForkBaseTest { function test_increasePosition_emits_user_not_router() public { if (address(router) == address(0)) return; - (address trader, uint256 positionId) = _openRealPosition(); + (bool ok, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; uint128 qtyBefore = position.getPosition(positionId).quantity; deal(address(usdcE), trader, 3e6); @@ -114,7 +115,8 @@ contract OperatorUpgradeForkTest is ForkBaseTest { function test_decreasePosition_emits_user_not_router() public { if (address(router) == address(0)) return; - (address trader, uint256 positionId) = _openRealPosition(); + (bool ok, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; uint128 qtyBefore = position.getPosition(positionId).quantity; vm.startPrank(trader); @@ -136,7 +138,8 @@ contract OperatorUpgradeForkTest is ForkBaseTest { function test_closePosition_emits_user_not_router() public { if (address(router) == address(0)) return; - (address trader, uint256 positionId) = _openRealPosition(); + (bool ok, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; vm.startPrank(trader); position.setApprovalForAll(address(router), true); @@ -157,7 +160,8 @@ contract OperatorUpgradeForkTest is ForkBaseTest { function test_claimPayout_emits_user_not_router() public { if (address(router) == address(0)) return; - (address trader, uint256 positionId) = _openRealPosition(); + (bool ok, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; ISignalsPosition.Position memory pos = position.getPosition(positionId); ISignalsCore.Market memory m = core.getMarket(pos.marketId); @@ -205,7 +209,8 @@ contract OperatorUpgradeForkTest is ForkBaseTest { function test_nonOwner_cannot_use_router_lifecycle() public { if (address(router) == address(0)) return; - (address trader, uint256 positionId) = _openRealPosition(); + (bool ok, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; // Trader approves router vm.prank(trader); @@ -226,8 +231,10 @@ contract OperatorUpgradeForkTest is ForkBaseTest { // Helpers // ============================================================ - function _openRealPosition() internal returns (address trader, uint256 positionId) { - (uint256 marketId, ISignalsCore.Market memory m) = _findActiveMarket(); + function _tryOpenRealPosition() internal returns (bool ok, address trader, uint256 positionId) { + (bool found, uint256 marketId, ISignalsCore.Market memory m) = _tryFindActiveMarket(); + if (!found) return (false, trader, 0); + trader = makeAddr("forkTrader"); deal(address(usdcE), trader, 10e6); @@ -245,25 +252,7 @@ contract OperatorUpgradeForkTest is ForkBaseTest { 5e6 ); vm.stopPrank(); - } - - function _findActiveMarket() internal view returns (uint256 marketId, ISignalsCore.Market memory m) { - uint256 upper = core.nextMarketId() + 5; - for (uint256 i = upper; i > 0; --i) { - try core.getMarket(i) returns (ISignalsCore.Market memory candidate) { - if ( - candidate.isSeeded && - !candidate.settled && - block.timestamp >= candidate.startTimestamp && - block.timestamp <= candidate.endTimestamp - ) { - return (i, candidate); - } - } catch { - continue; - } - } - revert("no active market"); + return (true, trader, positionId); } /// @dev Assert that PositionIncreased/Decreased/Closed/Claimed and TradeFeeCharged diff --git a/test/foundry/fork/live/PrimarySettlementFork.t.sol b/test/foundry/fork/live/PrimarySettlementFork.t.sol new file mode 100644 index 0000000..2cc3da1 --- /dev/null +++ b/test/foundry/fork/live/PrimarySettlementFork.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "../base/ForkProtocolTest.sol"; +import "../base/ForkRedstoneFFI.sol"; +import "../../../../contracts/interfaces/ISignalsCore.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title PrimarySettlementForkTest +/// @notice Advisory created-market smoke for the live Redstone primary-settlement path. +contract PrimarySettlementForkTest is ForkProtocolTest, ForkRedstoneFFI { + IERC20 internal ctUSDToken; + + function setUp() public override(ForkBaseTest, ForkProtocolTest) { + ForkProtocolTest.setUp(); + ctUSDToken = IERC20(paymentToken); + } + + function test_primary_settlement_smoke_uses_live_redstone_payload() public { + if (_isDevEnv()) return; + + uint64 targetBatch = _targetBatchAfter(uint64(block.timestamp + 120)); + uint256 marketId = _createUniformMarketForBatch(targetBatch); + + address trader = makeAddr("primarySettlementTrader"); + _fundAndApprove(trader, 100_000_000); + + uint128 quantity = 1_000_000; + uint256 maxCost = core.calculateOpenCost(marketId, 0, 1, quantity) + 1_000_000; + vm.prank(trader); + core.openPosition(marketId, 0, 1, quantity, maxCost); + + uint64 settlementTimestamp = core.getMarket(marketId).settlementTimestamp; + vm.warp(uint256(settlementTimestamp) + 1); + + bool submitted = _trySubmitPrimarySettlementSample(marketId); + if (!submitted) return; + + vm.warp(uint256(settlementTimestamp) + core.settlementSubmitWindow() + 1); + + vm.prank(ownerSafe); + core.finalizePrimarySettlement(marketId); + + vm.prank(ownerSafe); + core.requestSettlementChunks(marketId, 4); + + ISignalsCore.Market memory market = core.getMarket(marketId); + assertTrue(market.settled, "market not settled"); + assertFalse(market.failed, "market marked failed"); + assertGt(market.settlementFinalizedAt, 0, "primary finalize timestamp missing"); + } +} diff --git a/test/foundry/fork/RouterFork.t.sol b/test/foundry/fork/live/RouterFork.t.sol similarity index 78% rename from test/foundry/fork/RouterFork.t.sol rename to test/foundry/fork/live/RouterFork.t.sol index bbdb49b..97b0c94 100644 --- a/test/foundry/fork/RouterFork.t.sol +++ b/test/foundry/fork/live/RouterFork.t.sol @@ -1,18 +1,18 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import "./base/ForkBaseTest.sol"; -import "../../../contracts/interfaces/IAlgebraSwapRouter.sol"; -import "../../../contracts/interfaces/ISignalsCore.sol"; -import "../../../contracts/interfaces/ISignalsPosition.sol"; -import "../../../contracts/router/SignalsRouter.sol"; +import "../base/ForkLiveMarketTest.sol"; +import "../../../../contracts/interfaces/IAlgebraSwapRouter.sol"; +import "../../../../contracts/interfaces/ISignalsCore.sol"; +import "../../../../contracts/interfaces/ISignalsPosition.sol"; +import "../../../../contracts/router/SignalsRouter.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /// @title RouterForkTest /// @notice Fork tests against live Citrea mainnet — REAL Satsuma swap + REAL Core. /// Requires an active market. Run with: -/// PROD_RPC_URL=... FOUNDRY_PROFILE=fork FORK_ENV=prod forge test --match-contract RouterForkTest -vv -contract RouterForkTest is ForkBaseTest { +/// PROD_RPC_URL=... FOUNDRY_PROFILE=fork FORK_ENV=prod forge test --match-contract RouterForkTest --match-path 'test/foundry/fork/live/**' -vv +contract RouterForkTest is ForkLiveMarketTest { IAlgebraSwapRouter internal liveSwapRouter; IERC20 internal usdcE; IERC20 internal ctUSDToken; @@ -70,8 +70,8 @@ contract RouterForkTest is ForkBaseTest { // ============================================================ function test_fullFlow_openPositionWithSwap() public { - if (address(liveSwapRouter) == address(0)) return; - (SignalsRouter router, uint256 marketId, ISignalsCore.Market memory m) = _deployRealRouter(); + (bool ok, SignalsRouter router, uint256 marketId, ISignalsCore.Market memory m) = _tryDeployRealRouter(); + if (!ok) return; address trader = makeAddr("openTrader"); deal(address(usdcE), trader, 5e6); @@ -99,8 +99,8 @@ contract RouterForkTest is ForkBaseTest { } function test_fullFlow_increasePositionWithSwap() public { - if (address(liveSwapRouter) == address(0)) return; - (SignalsRouter router, address trader, uint256 positionId) = _openRealPosition(); + (bool ok, SignalsRouter router, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; uint256 qtyBefore = position.getPosition(positionId).quantity; deal(address(usdcE), trader, 3e6); @@ -116,8 +116,8 @@ contract RouterForkTest is ForkBaseTest { } function test_fullFlow_decreasePositionWithSwap() public { - if (address(liveSwapRouter) == address(0)) return; - (SignalsRouter router, address trader, uint256 positionId) = _openRealPosition(); + (bool ok, SignalsRouter router, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; uint256 qtyBefore = position.getPosition(positionId).quantity; uint256 ctUSDBefore = ctUSDToken.balanceOf(trader); @@ -133,8 +133,8 @@ contract RouterForkTest is ForkBaseTest { } function test_fullFlow_closePositionWithSwap() public { - if (address(liveSwapRouter) == address(0)) return; - (SignalsRouter router, address trader, uint256 positionId) = _openRealPosition(); + (bool ok, SignalsRouter router, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; vm.startPrank(trader); position.setApprovalForAll(address(router), true); @@ -145,8 +145,8 @@ contract RouterForkTest is ForkBaseTest { } function test_fullFlow_claimPayoutWithSwap() public { - if (address(liveSwapRouter) == address(0)) return; - (SignalsRouter router, address trader, uint256 positionId) = _openRealPosition(); + (bool ok, SignalsRouter router, address trader, uint256 positionId) = _tryOpenRealPosition(); + if (!ok) return; // Get market and position info ISignalsPosition.Position memory pos = position.getPosition(positionId); @@ -198,11 +198,15 @@ contract RouterForkTest is ForkBaseTest { // Helpers // ============================================================ - function _deployRealRouter() + function _tryDeployRealRouter() internal - returns (SignalsRouter router, uint256 marketId, ISignalsCore.Market memory m) + returns (bool ok, SignalsRouter router, uint256 marketId, ISignalsCore.Market memory m) { - (marketId, m) = _findActiveMarket(); + if (address(liveSwapRouter) == address(0)) return (false, router, 0, m); + + (bool found, uint256 activeMarketId, ISignalsCore.Market memory activeMarket) = _tryFindActiveMarket(); + if (!found) return (false, router, 0, m); + router = new SignalsRouter( address(core), address(position), @@ -212,12 +216,17 @@ contract RouterForkTest is ForkBaseTest { address(this) ); router.setAllowedToken(address(usdcE), true); + return (true, router, activeMarketId, activeMarket); } - function _openRealPosition() internal returns (SignalsRouter router, address trader, uint256 positionId) { + function _tryOpenRealPosition() + internal + returns (bool ok, SignalsRouter router, address trader, uint256 positionId) + { uint256 marketId; ISignalsCore.Market memory m; - (router, marketId, m) = _deployRealRouter(); + (ok, router, marketId, m) = _tryDeployRealRouter(); + if (!ok) return (false, router, trader, 0); trader = makeAddr("posTrader"); deal(address(usdcE), trader, 10e6); @@ -239,24 +248,6 @@ contract RouterForkTest is ForkBaseTest { 5e6 ); vm.stopPrank(); - } - - function _findActiveMarket() internal view returns (uint256 marketId, ISignalsCore.Market memory m) { - uint256 upper = core.nextMarketId() + 5; - for (uint256 i = upper; i > 0; --i) { - try core.getMarket(i) returns (ISignalsCore.Market memory candidate) { - if ( - candidate.isSeeded && - !candidate.settled && - block.timestamp >= candidate.startTimestamp && - block.timestamp <= candidate.endTimestamp - ) { - return (i, candidate); - } - } catch { - continue; - } - } - revert("no active market - run during market hours"); + return (true, router, trader, positionId); } }