diff --git a/contracts/src/v0.8/keystone/MockKeystoneForwarder.sol b/contracts/src/v0.8/keystone/MockKeystoneForwarder.sol new file mode 100644 index 0000000000..f06d8751e8 --- /dev/null +++ b/contracts/src/v0.8/keystone/MockKeystoneForwarder.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IReceiver} from "./interfaces/IReceiver.sol"; +import {IRouter} from "./interfaces/IRouter.sol"; +import {ITypeAndVersion} from "../shared/interfaces/ITypeAndVersion.sol"; + +import {OwnerIsCreator} from "../shared/access/OwnerIsCreator.sol"; + +/// @notice Simplified mock version of KeystoneForwarder for testing purposes. +/// The report function is permissionless and skips all validations. +contract MockKeystoneForwarder is OwnerIsCreator, ITypeAndVersion, IRouter { + /// @notice This error is returned when the report is shorter than REPORT_METADATA_LENGTH, + /// which is the minimum length of a report. + error InvalidReport(); + + struct Transmission { + address transmitter; + // This is true if the receiver is not a contract or does not implement the `IReceiver` interface. + bool invalidReceiver; + // Whether the transmission attempt was successful. If `false`, the transmission can be retried + // with an increased gas limit. + bool success; + // The amount of gas allocated for the `IReceiver.onReport` call. uint80 allows storing gas for known EVM block + // gas limits. Ensures that the minimum gas requested by the user is available during the transmission attempt. + // If the transmission fails (indicated by a `false` success state), it can be retried with an increased gas limit. + uint80 gasLimit; + } + + /// @notice Emitted when a report is processed + /// @param result The result of the attempted delivery. True if successful. + event ReportProcessed( + address indexed receiver, + bytes32 indexed workflowExecutionId, + bytes2 indexed reportId, + bool result + ); + + string public constant override typeAndVersion = "MockKeystoneForwarder 1.0.0"; + + constructor() OwnerIsCreator() { + s_forwarders[address(this)] = true; + } + + uint256 internal constant METADATA_LENGTH = 109; + uint256 internal constant FORWARDER_METADATA_LENGTH = 45; + + /// @dev This is the gas required to store `success` after the report is processed. + /// It is a warm storage write because of the packed struct. In practice it will cost less. + uint256 internal constant INTERNAL_GAS_REQUIREMENTS_AFTER_REPORT = 5_000; + /// @dev This is the gas required to store the transmission struct and perform other checks. + uint256 internal constant INTERNAL_GAS_REQUIREMENTS = 25_000 + INTERNAL_GAS_REQUIREMENTS_AFTER_REPORT; + /// @dev This is the minimum gas required to route a report. This includes internal gas requirements + /// as well as the minimum gas that the user contract will receive. 30k * 3 gas is to account for + /// cases where consumers need close to the 30k limit provided in the supportsInterface check. + uint256 internal constant MINIMUM_GAS_LIMIT = INTERNAL_GAS_REQUIREMENTS + 30_000 * 3 + 10_000; + + // ================================================================ + // │ Router │ + // ================================================================ + + mapping(address forwarder => bool isForwarder) internal s_forwarders; + mapping(bytes32 transmissionId => Transmission transmission) internal s_transmissions; + + function addForwarder(address forwarder) external onlyOwner { + s_forwarders[forwarder] = true; + emit ForwarderAdded(forwarder); + } + + function removeForwarder(address forwarder) external onlyOwner { + s_forwarders[forwarder] = false; + emit ForwarderRemoved(forwarder); + } + + function route( + bytes32 transmissionId, + address transmitter, + address receiver, + bytes calldata metadata, + bytes calldata validatedReport + ) public returns (bool) { + s_transmissions[transmissionId].transmitter = transmitter; + s_transmissions[transmissionId].gasLimit = uint80(gasleft()); + + // Always call onReport on the receiver + bool success; + bytes memory payload = abi.encodeCall(IReceiver.onReport, (metadata, validatedReport)); + + assembly { + // call and return whether we succeeded. ignore return data + // call(gas,addr,value,argsOffset,argsLength,retOffset,retLength) + success := call(gas(), receiver, 0, add(payload, 0x20), mload(payload), 0x0, 0x0) + } + + s_transmissions[transmissionId].success = success; + return success; + } + + function getTransmissionId( + address receiver, + bytes32 workflowExecutionId, + bytes2 reportId + ) public pure returns (bytes32) { + // This is slightly cheaper compared to `keccak256(abi.encode(receiver, workflowExecutionId, reportId));` + return keccak256(bytes.concat(bytes20(uint160(receiver)), workflowExecutionId, reportId)); + } + + function getTransmissionInfo( + address receiver, + bytes32 workflowExecutionId, + bytes2 reportId + ) external view returns (TransmissionInfo memory) { + bytes32 transmissionId = getTransmissionId(receiver, workflowExecutionId, reportId); + + Transmission memory transmission = s_transmissions[transmissionId]; + + TransmissionState state; + + if (transmission.transmitter == address(0)) { + state = IRouter.TransmissionState.NOT_ATTEMPTED; + } else if (transmission.invalidReceiver) { + state = IRouter.TransmissionState.INVALID_RECEIVER; + } else { + state = transmission.success ? IRouter.TransmissionState.SUCCEEDED : IRouter.TransmissionState.FAILED; + } + + return + TransmissionInfo({ + gasLimit: transmission.gasLimit, + invalidReceiver: transmission.invalidReceiver, + state: state, + success: transmission.success, + transmissionId: transmissionId, + transmitter: transmission.transmitter + }); + } + + /// @notice Get transmitter of a given report or 0x0 if it wasn't transmitted yet + function getTransmitter( + address receiver, + bytes32 workflowExecutionId, + bytes2 reportId + ) external view returns (address) { + return s_transmissions[getTransmissionId(receiver, workflowExecutionId, reportId)].transmitter; + } + + function isForwarder(address forwarder) external view returns (bool) { + return s_forwarders[forwarder]; + } + + // ================================================================ + // │ Forwarder │ + // ================================================================ + + /// @notice Simplified permissionless report function that skips all validations + /// and does not call onReport on consumer contracts + function report( + address receiver, + bytes calldata rawReport, + bytes calldata reportContext, + bytes[] calldata signatures + ) external { + if (rawReport.length < METADATA_LENGTH) { + revert InvalidReport(); + } + + bytes32 workflowExecutionId; + bytes2 reportId; + { + uint64 configId; + (workflowExecutionId, configId, reportId) = _getMetadata(rawReport); + } + + // Skip all validations and signature checks + // Skip onReport call to consumer contracts + bool success = this.route( + getTransmissionId(receiver, workflowExecutionId, reportId), + msg.sender, + receiver, + rawReport[FORWARDER_METADATA_LENGTH:METADATA_LENGTH], + rawReport[METADATA_LENGTH:] + ); + + emit ReportProcessed(receiver, workflowExecutionId, reportId, success); + } + + // solhint-disable-next-line chainlink-solidity/explicit-returns + function _getMetadata( + bytes memory rawReport + ) internal pure returns (bytes32 workflowExecutionId, uint64 configId, bytes2 reportId) { + // (first 32 bytes of memory contain length of the report) + // version offset 32, size 1 + // workflow_execution_id offset 33, size 32 + // timestamp offset 65, size 4 + // don_id offset 69, size 4 + // don_config_version, offset 73, size 4 + // workflow_cid offset 77, size 32 + // workflow_name offset 109, size 10 + // workflow_owner offset 119, size 20 + // report_id offset 139, size 2 + assembly { + workflowExecutionId := mload(add(rawReport, 33)) + // shift right by 24 bytes to get the combined don_id and don_config_version + configId := shr(mul(24, 8), mload(add(rawReport, 69))) + reportId := mload(add(rawReport, 139)) + } + } +} \ No newline at end of file diff --git a/deploy-mock-keystone-forwarder.js b/deploy-mock-keystone-forwarder.js new file mode 100644 index 0000000000..78164cd4b2 --- /dev/null +++ b/deploy-mock-keystone-forwarder.js @@ -0,0 +1,147 @@ +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +// Try to load dotenv if available +try { + require("dotenv").config(); +} catch (e) { + console.log("dotenv not installed, reading .env manually..."); + if (fs.existsSync(".env")) { + const envConfig = fs.readFileSync(".env", "utf8"); + envConfig.split("\n").forEach(line => { + const [key, value] = line.split("="); + if (key && value) { + process.env[key.trim()] = value.trim(); + } + }); + } +} + +// Load ethers dynamically +let ethers; +try { + ethers = require("ethers"); +} catch (e) { + console.error("ethers.js not found. Installing..."); + execSync("npm install ethers@6", { stdio: 'inherit' }); + ethers = require("ethers"); +} + +async function main() { + // Check environment variables + if (!process.env.RPC_URL || !process.env.PRIVATE_KEY) { + console.error("Missing RPC_URL or PRIVATE_KEY in .env file"); + console.error("\nCreate a .env file with:"); + console.error("RPC_URL=https://your-rpc-url"); + console.error("PRIVATE_KEY=your-private-key"); + process.exit(1); + } + + console.log("Building MockKeystoneForwarder contract..."); + + try { + // Build only the MockKeystoneForwarder contract + execSync("cd contracts && forge build --contracts src/v0.8/keystone/MockKeystoneForwarder.sol", { stdio: 'inherit' }); + } catch (error) { + console.error("Forge build failed. Trying direct compilation with solc..."); + + // Try direct solc compilation + const contractPath = path.join(__dirname, "contracts/src/v0.8/keystone/MockKeystoneForwarder.sol"); + const buildDir = path.join(__dirname, "build"); + + if (!fs.existsSync(buildDir)) { + fs.mkdirSync(buildDir); + } + + try { + execSync(`cd contracts && solc --base-path . --include-path ./src --abi --bin --overwrite -o ../build src/v0.8/keystone/MockKeystoneForwarder.sol`); + + const abi = JSON.parse(fs.readFileSync(path.join(buildDir, "MockKeystoneForwarder.abi"), "utf8")); + const bytecode = "0x" + fs.readFileSync(path.join(buildDir, "MockKeystoneForwarder.bin"), "utf8").trim(); + + return await deployContract(abi, bytecode); + } catch (solcError) { + console.error("Direct compilation also failed:", solcError.message); + process.exit(1); + } + } + + // Read the compiled artifact from forge + const artifactPath = path.join(__dirname, "contracts/foundry-artifacts/MockKeystoneForwarder.sol/MockKeystoneForwarder.json"); + + if (!fs.existsSync(artifactPath)) { + console.error("Compiled artifact not found at:", artifactPath); + // Try alternative path + const altPath = path.join(__dirname, "foundry-artifacts/MockKeystoneForwarder.sol/MockKeystoneForwarder.json"); + if (fs.existsSync(altPath)) { + const artifact = JSON.parse(fs.readFileSync(altPath, "utf8")); + const abi = artifact.abi; + const bytecode = artifact.bytecode.object; + return await deployContract(abi, bytecode); + } + process.exit(1); + } + + const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); + const abi = artifact.abi; + const bytecode = artifact.bytecode.object; + + await deployContract(abi, bytecode); +} + +async function deployContract(abi, bytecode) { + // Setup provider and wallet + const provider = new ethers.JsonRpcProvider(process.env.RPC_URL); + const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider); + + console.log("\nDeploying from address:", wallet.address); + + // Check balance + const balance = await provider.getBalance(wallet.address); + console.log("Balance:", ethers.formatEther(balance), "ETH"); + + if (balance === 0n) { + console.error("Insufficient balance for deployment!"); + process.exit(1); + } + + // Deploy contract + const factory = new ethers.ContractFactory(abi, bytecode, wallet); + console.log("\nDeploying MockKeystoneForwarder..."); + + const contract = await factory.deploy(); + const tx = contract.deploymentTransaction(); + + console.log("Transaction hash:", tx.hash); + console.log("Waiting for confirmation..."); + + await contract.waitForDeployment(); + const address = await contract.getAddress(); + + console.log("\n✅ MockKeystoneForwarder deployed to:", address); + + // Verify deployment + const typeAndVersion = await contract.typeAndVersion(); + console.log("Type and Version:", typeAndVersion); + + // Save deployment info + const deploymentInfo = { + address: address, + deployer: wallet.address, + transactionHash: tx.hash, + timestamp: new Date().toISOString(), + network: { + chainId: Number((await provider.getNetwork()).chainId), + rpcUrl: process.env.RPC_URL + } + }; + + fs.writeFileSync("deployment.json", JSON.stringify(deploymentInfo, null, 2)); + console.log("\nDeployment info saved to deployment.json"); +} + +main().catch(error => { + console.error("\nDeployment failed:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000000..b67d2cf3cd --- /dev/null +++ b/deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +echo "📦 Installing dependencies..." +npm install ethers dotenv + +echo -e "\n🔧 Running deployment script..." +node deploy-mock-keystone-forwarder.js \ No newline at end of file diff --git a/deployment.json b/deployment.json new file mode 100644 index 0000000000..a7bd29737a --- /dev/null +++ b/deployment.json @@ -0,0 +1,10 @@ +{ + "address": "0x15fC6ae953E024d975e77382eEeC56A9101f9F88", + "deployer": "0xE5835085dC0E95c8BED29841b43b0430e863d95F", + "transactionHash": "0x96895bfcc8f7b584a2c3605899fb79f933150d4cf10ec5260f2a07333784a1b2", + "timestamp": "2025-07-15T22:22:28.260Z", + "network": { + "chainId": 11155111, + "rpcUrl": "https://sepolia.infura.io/v3/dbe1bfd45172477084dfe080e0754c1e" + } +} \ No newline at end of file diff --git a/verify-sepolia.js b/verify-sepolia.js new file mode 100644 index 0000000000..d4971ba8f9 --- /dev/null +++ b/verify-sepolia.js @@ -0,0 +1,75 @@ +const fs = require("fs"); +const { execSync } = require("child_process"); + +// Try to load dotenv if available +try { + require("dotenv").config(); +} catch (e) { + // Read .env file manually if dotenv is not installed + if (fs.existsSync(".env")) { + const envConfig = fs.readFileSync(".env", "utf8"); + envConfig.split("\n").forEach(line => { + const [key, value] = line.split("="); + if (key && value) { + process.env[key.trim()] = value.trim(); + } + }); + } +} + +async function main() { + // Check for deployment info + if (!fs.existsSync("deployment.json")) { + console.error("deployment.json not found. Please deploy the contract first."); + process.exit(1); + } + + const deployment = JSON.parse(fs.readFileSync("deployment.json", "utf8")); + const contractAddress = deployment.address; + + console.log("Verifying MockKeystoneForwarder on Sepolia"); + console.log("Contract Address:", contractAddress); + + // Check for Etherscan API key + if (!process.env.ETHERSCAN_API_KEY) { + console.error("\nMissing ETHERSCAN_API_KEY in .env file"); + console.error("Add to your .env file:"); + console.error("ETHERSCAN_API_KEY=your-etherscan-api-key"); + console.error("\nGet your API key from: https://etherscan.io/apis"); + process.exit(1); + } + + try { + // Add delay to avoid rate limit + console.log("\nWaiting 3 seconds to avoid rate limit..."); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const cmd = `cd contracts && forge verify-contract \ + --chain sepolia \ + --etherscan-api-key ${process.env.ETHERSCAN_API_KEY} \ + --watch \ + --retry 3 \ + ${contractAddress} \ + src/v0.8/keystone/MockKeystoneForwarder.sol:MockKeystoneForwarder`; + + console.log("\nRunning verification..."); + execSync(cmd, { stdio: 'inherit' }); + + console.log("\n✅ Contract verified successfully!"); + console.log(`View on Sepolia Etherscan: https://sepolia.etherscan.io/address/${contractAddress}#code`); + + } catch (error) { + console.error("\nVerification failed. You can verify manually at:"); + console.error(`https://sepolia.etherscan.io/verifyContract`); + console.error("\nContract details:"); + console.error("- Address:", contractAddress); + console.error("- Compiler: v0.8.19"); + console.error("- Optimization: Enabled (1000000 runs)"); + console.error("- Contract name: MockKeystoneForwarder"); + } +} + +main().catch(error => { + console.error("\nError:", error); + process.exit(1); +}); \ No newline at end of file