diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ecd5b3a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.solx linguist-language=Solidity \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d531853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.solx": "solidity" + } +} diff --git a/README.md b/README.md index 2fa7622..6a64936 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,36 @@ -# Soldeer Minimal +# SOLX -**Minimal Forge, Soldeer, and Bun template.** +**Solidity × Any-Language Transpiler for Foundry.** -Soldeer Minimal uses Soldeer package manager instead of git modules and can run TypeScript out of box. +Write Solidity AND **any other programming language** at the same time! + + ## Usage +> [!IMPORTANT] +> Proof of concept. For research purposes only. + +**Clone** + +```shell +git clone https://github.com/ZeroEkkusu/solx +``` + **Install** ```shell soldeer install & bun install ``` +**Hook** + +``` +source solx/hooks.sh +``` + +This will hook SOLX transpiler to Forge. + **Build** ```shell @@ -24,6 +43,43 @@ forge build forge test ``` +**Experiment** + +> [!NOTE] +> Try it out: [`test/Example.solx`](./test/Example.solx) + + + +Specify variables to sync between Solidity and TypeScript: + +```solidity +uint256 a; +// @typescript-start (uint256 a) +a++; +// @typescript-end () +assertEq(a, 1); +``` + +Specify variables to clone from TypeScript: + +```solidity +uint256 a = 1; +// @typescript-start () +const b = 1; +// @typescript-end (uint256 b) +assertEq(a, b); +``` + +Use `console.log` in TypeScript: + +```solidity +// @typescript-start () +console.log('👀'); +// @typescript-end () +``` + +Only TypeScript is supported currently. + ## License ​ Licensed under either of diff --git a/bun.lockb b/bun.lockb index f8cbfde..2757524 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..f30959d Binary files /dev/null and b/demo.gif differ diff --git a/foundry.toml b/foundry.toml index 71624f9..d4284f5 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,5 +1,5 @@ [profile.default] -src = "src" +test = "out/solx" out = "out" libs = ["dependencies"] verbosity = 2 diff --git a/package.json b/package.json index f471dfe..b8b290f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "devDependencies": { "@types/bun": "^1.1.6" + }, + "dependencies": { + "ethers": "^6.13.2" } } \ No newline at end of file diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol deleted file mode 100644 index 7d20403..0000000 --- a/script/Deploy.s.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity ^0.8.26; - -import "@forge-std/Script.sol"; - -contract Delpoy is Script { - function run() public { - vm.broadcast(vm.promptSecretUint("Deployer private key")); - } -} diff --git a/solx/hooks.sh b/solx/hooks.sh new file mode 100644 index 0000000..9ab031e --- /dev/null +++ b/solx/hooks.sh @@ -0,0 +1,10 @@ +# Override for 'forge build' and 'forge test' +forge() { + if [ "$1" = "build" ]; then + bun solx/transpiler.ts && command forge build "${@:2}" + elif [ "$1" = "test" ]; then + bun solx/transpiler.ts && command forge test "${@:2}" + else + command forge "$@" + fi +} diff --git a/solx/transpiler.ts b/solx/transpiler.ts new file mode 100644 index 0000000..71156a9 --- /dev/null +++ b/solx/transpiler.ts @@ -0,0 +1,217 @@ +import * as fs from "fs"; +import * as path from "path"; + +interface ProcessedContent { + solidity: string; + typescript: string; +} + +function processSolidityFile(inputPath: string): void { + const content = fs.readFileSync(inputPath, "utf-8"); + const { solidity, typescript } = extractTypeScriptBlock(content); + + const outputDir = path.join(process.cwd(), "out", "solx"); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const outputSolPath = path.join( + outputDir, + path.basename(inputPath).replace(".solx", ".sol") + ); + const outputTsPath = path.join(outputDir, "example.ts"); + + fs.writeFileSync(outputSolPath, solidity); + fs.writeFileSync(outputTsPath, typescript); + + /*console.log(`Processed ${inputPath}`); + console.log(`Generated ${outputSolPath}`); + console.log(`Generated ${outputTsPath}`);*/ +} + +function extractTypeScriptBlock(content: string): ProcessedContent { + const tsBlockRegex = + /\/\/ @typescript-start\s*\((.*?)\)([\s\S]*?)\/\/ @typescript-end\s*\((.*?)\)/; + const match = tsBlockRegex.exec(content); + + if (!match) { + throw new Error("No TypeScript block found in the Solidity file."); + } + + const [fullMatch, inputVars, tsCode, outputVars] = match; + const inputVarList = inputVars + ? inputVars.split(",").map((v) => v.trim()) + : []; + const outputVarList = outputVars + ? outputVars.split(",").map((v) => v.trim()) + : []; + + const allOutputVars = [...new Set([...outputVarList, ...inputVarList])]; + const newOutputVars = outputVarList.filter((v) => !inputVarList.includes(v)); + const existingOutputVars = allOutputVars.filter( + (v) => !newOutputVars.includes(v) + ); + const hasConsoleLog = tsCode.includes("console.log"); + + let solidity = content.replace( + fullMatch, + ` + string[] memory cmd = new string[](${inputVarList.length > 0 ? "3" : "2"}); + cmd[0] = "bun"; + cmd[1] = "./out/solx/example.ts"; + ${ + inputVarList.length > 0 + ? `cmd[2] = vm.toString(abi.encode(${inputVarList + .map((v) => v.split(" ")[1]) + .join(", ")}));` + : "" + } + ${ + allOutputVars.length > 0 || hasConsoleLog + ? "bytes memory solx_decoded = vm.ffi(cmd);" + : "vm.ffi(cmd);" + } + ${ + allOutputVars.length > 0 || hasConsoleLog + ? ` + (${newOutputVars + .map((v) => { + const [type, name] = v.split(" "); + return `${addMemoryKeyword(type)} ${name}`; + }) + .join(", ")}${ + existingOutputVars.length > 0 + ? (newOutputVars.length > 0 ? ", " : "") + + existingOutputVars + .map((v, i) => { + const [type, name] = v.split(" "); + return `${addMemoryKeyword(type)} solx_temp_${i}`; + }) + .join(", ") + : "" + }${allOutputVars.length > 0 && hasConsoleLog ? ", " : ""}${ + hasConsoleLog ? "string memory solx_logs" : "" + }) = abi.decode(solx_decoded, (${allOutputVars + .map((v) => v.split(" ")[0]) + .join(", ")}${hasConsoleLog ? ", string" : ""})); + ${existingOutputVars + .map((v, i) => `${v.split(" ")[1]} = solx_temp_${i};`) + .join("\n ")}` + : "" + } + ${hasConsoleLog ? "console.log(solx_logs);" : ""} + ` + ); + + let typescript = ` +import { ethers } from "ethers"; + +${ + hasConsoleLog + ? ` +let solx_logs = ""; +let isFirstLog = true; +const originalConsoleLog = console.log; +console.log = (...args) => { + if (isFirstLog) { + solx_logs += args.join(' '); + isFirstLog = false; + } else { + solx_logs += "\\n " + args.join(' '); + } +}; +` + : "" +} + +const inputData = Bun.argv[2]; + +${ + inputVarList.length > 0 + ? ` +if (!inputData) { + console.error("No input data provided"); + process.exit(1); +} +` + : "" +} + +async function main() { + try { + const abi = new ethers.AbiCoder(); + ${ + inputVarList.length > 0 + ? ` + // Decode input based on the types specified in the Solidity FFI call + let [${inputVarList + .map((v) => v.split(" ")[1]) + .join(", ")}] = abi.decode([${inputVarList + .map((v) => `"${v.split(" ")[0]}"`) + .join(", ")}], inputData); + ` + : "" + } + + // User's TypeScript code + ${tsCode} + + ${ + hasConsoleLog + ? ` + // Restore original console.log + console.log = originalConsoleLog; + ` + : "" + } + + ${ + allOutputVars.length > 0 || hasConsoleLog + ? ` + // Encode output including logs and all input variables + const encodedOutput = abi.encode( + [...${JSON.stringify(allOutputVars.map((v) => v.split(" ")[0]))}${ + hasConsoleLog ? ', "string"' : "" + }], + [${allOutputVars.map((v) => v.split(" ")[1]).join(", ")}${ + hasConsoleLog ? ", solx_logs" : "" + }] + ); + + console.log(encodedOutput); + ` + : "" + } + } catch (error) { + console.error("An error occurred:", error); + process.exit(1); + } +} + +main(); +`; + + return { solidity, typescript }; +} + +function addMemoryKeyword(type: string): string { + // Always add 'memory' for array types + if (type.includes("[]")) { + return `${type} memory`; + } + + // For non-array types, add 'memory' if it's not a value type + if ( + !/^(uint\d*|int\d*|bool|address|bytes([1-9]|[12][0-9]|3[0-2]))(\[\])?$/.test( + type + ) + ) { + return `${type} memory`; + } + + return type; +} + +// Main execution +const inputFilePath = path.join(process.cwd(), "test", "Example.solx"); +processSolidityFile(inputFilePath); diff --git a/src/Contract.sol b/src/Contract.sol deleted file mode 100644 index 26185e7..0000000 --- a/src/Contract.sol +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity 0.8.26; - -contract Contract {} diff --git a/test/Contract.t.sol b/test/Contract.t.sol deleted file mode 100644 index 0419659..0000000 --- a/test/Contract.t.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -pragma solidity ^0.8.26; - -import "@forge-std/Test.sol"; - -contract ContractTest is Test { - function setUp() public {} - - function test() public {} -} diff --git a/test/Example.solx b/test/Example.solx new file mode 100644 index 0000000..0de3ceb --- /dev/null +++ b/test/Example.solx @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; + +contract PikachuTest is Test { + // Let's catch Pikachu ⚡️ in .solx! + + function test_catchPikachu() public { + string memory myPokemon; + uint256 randomness = vm.randomUint(0, 49); + // @typescript-start (string myPokemon, uint256 randomness) + console.log("Wow, I can write TypeScript inside Solidity!"); + const response = await fetch("https://dummyapi.online/api/pokemon"); + const json = await response.json(); + myPokemon = json[randomness].pokemon; + const isPikachu: boolean = myPokemon === "Pikachu"; + // @typescript-end (bool isPikachu) + console.log("I've caught", string.concat(myPokemon, isPikachu ? unicode" 🥹" : ".")); + // Did you notice that isPikachu was not defined? 😱 + } +} \ No newline at end of file