├── .prettierrc.json ├── .npmrc ├── src ├── lib │ ├── builder │ │ ├── __test__ │ │ │ ├── no-schema │ │ │ │ └── .gitkeep │ │ │ ├── valid-schema │ │ │ │ ├── .gitignore │ │ │ │ ├── schema.json │ │ │ │ ├── source.js │ │ │ │ └── index.ts │ │ │ ├── missing-required-field │ │ │ │ └── schema.json │ │ │ ├── invalid-schema-memory │ │ │ │ └── schema.json │ │ │ ├── invalid-schema-runtime │ │ │ │ └── schema.json │ │ │ ├── invalid-schema-timeout │ │ │ │ └── schema.json │ │ │ ├── invalid-schema-version │ │ │ │ └── schema.json │ │ │ ├── invalid-schema-execution-mode │ │ │ │ └── schema.json │ │ │ ├── invalid-schema-userargs │ │ │ │ └── schema.json │ │ │ └── invalid-schema-event-retry │ │ │ │ └── schema.json │ │ ├── index.ts │ │ ├── web3function.schema.json │ │ ├── Web3FunctionBuilder.spec.ts │ │ └── Web3FunctionBuilder.ts │ ├── loader │ │ ├── __test__ │ │ │ ├── source-missing │ │ │ │ └── .gitkeep │ │ │ └── just-ts │ │ │ │ ├── .env │ │ │ │ ├── userArgs.json │ │ │ │ ├── storage.json │ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── index.ts │ │ ├── Web3FunctionLoader.spec.ts │ │ └── Web3FunctionLoader.ts │ ├── uploader │ │ ├── __test__ │ │ │ ├── .gitignore │ │ │ ├── extra-file │ │ │ │ └── QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz │ │ │ ├── valid-tar │ │ │ │ └── QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz │ │ │ ├── no-schema-tar │ │ │ │ └── QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz │ │ │ └── malformed-schema-tar │ │ │ │ └── QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz │ │ ├── index.ts │ │ ├── Web3FunctionUploader.spec.ts │ │ └── Web3FunctionUploader.ts │ ├── index.ts │ ├── provider │ │ ├── index.ts │ │ ├── types │ │ │ └── index.ts │ │ ├── Web3FunctionMultiChainProvider.ts │ │ ├── Web3FunctionMultiChainProvider.spec.ts │ │ ├── Web3FunctionProxyProvider.ts │ │ └── Web3FunctionProxyProvider.spec.ts │ ├── types │ │ ├── Web3FunctionOperation.ts │ │ ├── Web3FunctionUserArgs.ts │ │ ├── index.ts │ │ ├── Web3FunctionResult.ts │ │ ├── Web3FunctionSchema.ts │ │ ├── Web3FunctionHandler.ts │ │ ├── Web3FunctionEvent.ts │ │ └── Web3FunctionContext.ts │ ├── runtime │ │ ├── types │ │ │ ├── Web3FunctionSandboxOptions.ts │ │ │ ├── index.ts │ │ │ ├── Web3FunctionRunnerPayload.ts │ │ │ └── Web3FunctionExecResult.ts │ │ ├── index.ts │ │ ├── sandbox │ │ │ ├── __test__ │ │ │ │ ├── not_allowed_env.ts │ │ │ │ ├── simple.ts │ │ │ │ ├── exceed_memory_usage.ts │ │ │ │ ├── blacklisted_host.ts │ │ │ │ └── memory_usage.ts │ │ │ ├── Web3FunctionThreadSandbox.ts │ │ │ ├── Web3FunctionAbstractSandbox.ts │ │ │ ├── Web3FunctionDockerSandbox.ts │ │ │ └── Web3FunctionThreadSandbox.spec.ts │ │ ├── __test__ │ │ │ ├── schema.json │ │ │ ├── simple.ts │ │ │ ├── invalid-return-no-canexec.ts │ │ │ ├── invalid-return.ts │ │ │ ├── invalid-return-calldata-address.ts │ │ │ ├── invalid-return-calldata-data.ts │ │ │ └── invalid-return-calldata-value.ts │ │ ├── Web3FunctionRunnerPool.spec.ts │ │ └── Web3FunctionRunnerPool.ts │ ├── net │ │ ├── index.ts │ │ ├── Web3FunctionNetHelper.ts │ │ ├── Web3FunctionHttpServer.ts │ │ ├── Web3FunctionHttpClient.spec.ts │ │ ├── Web3FunctionHttpProxy.spec.ts │ │ ├── Web3FunctionHttpClient.ts │ │ └── Web3FunctionHttpProxy.ts │ ├── binaries │ │ ├── schema.ts │ │ ├── fetch.ts │ │ ├── deploy.ts │ │ └── benchmark.ts │ ├── deno.d.ts │ └── Web3Function.ts ├── web3-functions │ ├── callbacks │ │ ├── userArgs.json │ │ ├── schema.json │ │ └── index.ts │ ├── storage │ │ ├── storage.json │ │ ├── schema.json │ │ └── index.ts │ ├── secrets │ │ ├── .env.example │ │ ├── userArgs.json │ │ ├── schema.json │ │ └── index.ts │ ├── fails │ │ ├── escape-storage │ │ │ ├── storage.json │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── escape-cpu │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── escape-env │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── escape-file │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── escape-os │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── no-result │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── blacklisted-host │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── download-limit │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── escape-memory │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── escape-timeout │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── not-registered │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── request-limit │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── syntax-error │ │ │ ├── schema.json │ │ │ └── index.js │ │ ├── upload-limit │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── rpc-provider-limit │ │ │ ├── schema.json │ │ │ └── index.ts │ │ ├── standard-plan-warning │ │ │ ├── schema.json │ │ │ └── index.ts │ │ └── unhandled-exception │ │ │ ├── schema.json │ │ │ └── index.ts │ ├── oracle │ │ ├── userArgs.json │ │ ├── schema.json │ │ └── index.ts │ ├── schema.json │ ├── event │ │ ├── schema.json │ │ ├── index.ts │ │ └── log.json │ ├── custom-error │ │ ├── schema.json │ │ └── index.ts │ ├── wait.ts │ ├── axios.ts │ └── index.ts ├── hardhat │ ├── tasks │ │ ├── index.ts │ │ ├── deploy.ts │ │ └── run.ts │ ├── index.ts │ ├── types │ │ └── index.ts │ ├── constants │ │ └── index.ts │ ├── hre │ │ ├── type-extensions.ts │ │ ├── index.ts │ │ └── W3fHardhatPlugin.ts │ └── provider │ │ └── index.ts └── bin │ └── index.ts ├── .prettierignore ├── .env.example ├── .husky └── pre-push ├── tsconfig.build.json ├── .eslintignore ├── .changeset ├── config.json └── README.md ├── jest.setup.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── jest.config.ts ├── sonar-project.properties ├── .github └── workflows │ └── test-scan.yml ├── CHANGELOG.md └── package.json /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/no-schema/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/loader/__test__/source-missing/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/uploader/__test__/.gitignore: -------------------------------------------------------------------------------- 1 | .temp_*/ -------------------------------------------------------------------------------- /src/lib/builder/__test__/valid-schema/.gitignore: -------------------------------------------------------------------------------- 1 | index.js -------------------------------------------------------------------------------- /src/lib/builder/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionBuilder"; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | cache 3 | dist 4 | node_modules 5 | .tmp -------------------------------------------------------------------------------- /src/lib/uploader/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionUploader"; 2 | -------------------------------------------------------------------------------- /src/web3-functions/callbacks/userArgs.json: -------------------------------------------------------------------------------- 1 | { 2 | "canExec": false 3 | } 4 | -------------------------------------------------------------------------------- /src/hardhat/tasks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./run"; 2 | export * from "./deploy"; 3 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3Function"; 2 | export * from "./types"; 3 | -------------------------------------------------------------------------------- /src/lib/loader/__test__/just-ts/.env: -------------------------------------------------------------------------------- 1 | COINGECKO_API=https://api.coingecko.com/api/v3 2 | -------------------------------------------------------------------------------- /src/lib/loader/__test__/just-ts/userArgs.json: -------------------------------------------------------------------------------- 1 | { 2 | "currency": "ethereum" 3 | } 4 | -------------------------------------------------------------------------------- /src/web3-functions/storage/storage.json: -------------------------------------------------------------------------------- 1 | { 2 | "lastBlockNumber": "8200000" 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/loader/__test__/just-ts/storage.json: -------------------------------------------------------------------------------- 1 | { 2 | "lastBlockNumber": "8200000" 3 | } 4 | -------------------------------------------------------------------------------- /src/web3-functions/secrets/.env.example: -------------------------------------------------------------------------------- 1 | COINGECKO_API=https://api.coingecko.com/api/v3 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PROVIDER_URLS=https://rpc.ankr.com/eth_sepolia,https://rpc.ankr.com/polygon_amoy 2 | -------------------------------------------------------------------------------- /src/lib/loader/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionLoader"; 2 | export * from "./types"; 3 | -------------------------------------------------------------------------------- /src/lib/provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionProxyProvider"; 2 | export * from "./types"; 3 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-storage/storage.json: -------------------------------------------------------------------------------- 1 | { 2 | "myLastMessage": "Lorem ipsum" 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint && yarn build 5 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionOperation.ts: -------------------------------------------------------------------------------- 1 | export type Web3FunctionOperation = "onRun" | "onFail" | "onSuccess"; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/web3-functions/**/*", "src/**/__test__"] 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/runtime/types/Web3FunctionSandboxOptions.ts: -------------------------------------------------------------------------------- 1 | export interface Web3FunctionSandboxOptions { 2 | memoryLimit: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionRunner"; 2 | export * from "./Web3FunctionRunnerPool"; 3 | export * from "./types"; 4 | -------------------------------------------------------------------------------- /src/web3-functions/oracle/userArgs.json: -------------------------------------------------------------------------------- 1 | { 2 | "oracle": "0x71B9B0F6C999CBbB0FeF9c92B80D54e4973214da", 3 | "currency": "ethereum" 4 | } 5 | -------------------------------------------------------------------------------- /src/web3-functions/secrets/userArgs.json: -------------------------------------------------------------------------------- 1 | { 2 | "currency": "ethereum", 3 | "oracle": "0x71B9B0F6C999CBbB0FeF9c92B80D54e4973214da" 4 | } 5 | -------------------------------------------------------------------------------- /src/hardhat/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./tasks/index"; 2 | export * from "./hre/index"; 3 | export { Web3FunctionHardhat } from "./hre/W3fHardhatPlugin"; 4 | -------------------------------------------------------------------------------- /src/lib/net/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionHttpClient"; 2 | export * from "./Web3FunctionHttpServer"; 3 | export * from "./Web3FunctionNetHelper"; 4 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/__test__/not_allowed_env.ts: -------------------------------------------------------------------------------- 1 | try { 2 | console.log(Deno.env.get("HTTP_PROXY")); 3 | } catch { 4 | console.log("Passed"); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/__test__/simple.ts: -------------------------------------------------------------------------------- 1 | console.log(Deno.env.get("WEB3_FUNCTION_SERVER_PORT")); 2 | console.log(Deno.env.get("WEB3_FUNCTION_MOUNT_PATH")); 3 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionUserArgs.ts: -------------------------------------------------------------------------------- 1 | export interface Web3FunctionUserArgs { 2 | [key: string]: string | number | boolean | string[] | number[] | boolean[]; 3 | } 4 | -------------------------------------------------------------------------------- /src/web3-functions/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/missing-required-field/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/runtime/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Web3FunctionExecResult"; 2 | export * from "./Web3FunctionRunnerPayload"; 3 | export * from "./Web3FunctionSandboxOptions"; 4 | -------------------------------------------------------------------------------- /src/web3-functions/event/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/storage/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-cpu/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-env/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-file/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-os/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/no-result/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/__test__/exceed_memory_usage.ts: -------------------------------------------------------------------------------- 1 | const arr: string[] = []; 2 | while (arr.length < 1_000_000_000) { 3 | arr.push(`Do we have access to infinite memory?`); 4 | } 5 | -------------------------------------------------------------------------------- /src/web3-functions/fails/blacklisted-host/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/download-limit/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-memory/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-storage/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-timeout/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/not-registered/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/request-limit/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/syntax-error/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/upload-limit/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | cache/ 2 | dist/ 3 | node_modules 4 | src/resolvers/fails 5 | .tmp 6 | jest.config.ts 7 | jest.setup.ts 8 | __test__ 9 | src/web3-functions 10 | polyfill 11 | src/lib/deno.d.ts -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/__test__/blacklisted_host.ts: -------------------------------------------------------------------------------- 1 | try { 2 | let resp = await fetch("http://gelato.network"); 3 | console.log("Failed"); 4 | } catch { 5 | console.log("Passed"); 6 | } 7 | -------------------------------------------------------------------------------- /src/web3-functions/fails/rpc-provider-limit/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 256, 5 | "timeout": 60, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/standard-plan-warning/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/web3-functions/fails/unhandled-exception/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-memory/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 1024, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-runtime/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-2.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-timeout/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 3600, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-version/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "1.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/provider/types/index.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | 3 | export interface MultiChainProviderConfig { 4 | [key: number]: StaticJsonRpcProvider; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/simple.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | Web3Function.onRun(async () => { 4 | return { canExec: false, message: "Simple" }; 5 | }); 6 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/__test__/memory_usage.ts: -------------------------------------------------------------------------------- 1 | const array = new Uint8Array(5 * 1024 * 1024); 2 | 3 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 4 | await delay(2000); 5 | -------------------------------------------------------------------------------- /src/web3-functions/fails/syntax-error/index.js: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "nothing"; 2 | 3 | Web3Function.onRun(async (context) => { 4 | return { canExec: false, message: "Malformed import" }; 5 | }); 6 | -------------------------------------------------------------------------------- /src/web3-functions/custom-error/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": {}, 7 | "executionMode": "sequential" 8 | } -------------------------------------------------------------------------------- /src/web3-functions/callbacks/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": { 7 | "canExec": "boolean" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/invalid-return-no-canexec.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | Web3Function.onRun(async () => { 4 | return { canExec: undefined, message: "Simple" }; 5 | }); 6 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-execution-mode/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 10, 6 | "executionMode": "unknown", 7 | "userArgs": {} 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/uploader/__test__/extra-file/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gelatodigital/web3-functions-sdk/HEAD/src/lib/uploader/__test__/extra-file/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz -------------------------------------------------------------------------------- /src/lib/uploader/__test__/valid-tar/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gelatodigital/web3-functions-sdk/HEAD/src/lib/uploader/__test__/valid-tar/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-userargs/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": { 7 | "argument": "invalid" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/uploader/__test__/no-schema-tar/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gelatodigital/web3-functions-sdk/HEAD/src/lib/uploader/__test__/no-schema-tar/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz -------------------------------------------------------------------------------- /src/web3-functions/oracle/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": { 7 | "currency": "string", 8 | "oracle": "string" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/web3-functions/secrets/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "userArgs": { 7 | "currency": "string", 8 | "oracle": "string" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/invalid-return.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | Web3Function.onRun(async () => { 4 | const msg = "hello" as unknown as boolean; 5 | return { canExec: msg, message: "Simple" }; 6 | }); 7 | -------------------------------------------------------------------------------- /src/lib/uploader/__test__/malformed-schema-tar/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gelatodigital/web3-functions-sdk/HEAD/src/lib/uploader/__test__/malformed-schema-tar/QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2.tgz -------------------------------------------------------------------------------- /src/hardhat/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface W3fHardhatConfig { 2 | rootDir: string; 3 | debug: boolean; 4 | networks: string[]; 5 | } 6 | 7 | export interface W3fUserConfig { 8 | rootDir: string; 9 | debug: boolean; 10 | networks: string[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/invalid-schema-event-retry/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 256, 5 | "timeout": 30, 6 | "eventRetryInterval": 10, 7 | "eventRetryTtl": 1000000, 8 | "userArgs": {} 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/valid-schema/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "web3FunctionVersion": "2.0.0", 3 | "runtime": "js-1.0", 4 | "memory": 128, 5 | "timeout": 30, 6 | "eventRetryInterval": 60, 7 | "eventRetryTtl": 3600, 8 | "executionMode": "parallel", 9 | "userArgs": {} 10 | } 11 | -------------------------------------------------------------------------------- /src/hardhat/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const W3f_ROOT_DIR = "./web3-functions"; 2 | export const MAX_RPC_LIMIT = 500; 3 | export const MAX_DOWNLOAD_LIMIT = 10 * 1024 * 1024; 4 | export const MAX_UPLOAD_LIMIT = 5 * 1024 * 1024; 5 | export const MAX_REQUEST_LIMIT = 510; 6 | export const MAX_STORAGE_LIMIT = 10024; 7 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/valid-schema/source.js: -------------------------------------------------------------------------------- 1 | // src/lib/builder/__test__/valid-schema/index.ts 2 | import { 3 | Web3Function 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | Web3Function.onRun(async (context) => { 6 | return { 7 | canExec: false, 8 | message: "simple-test" 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/invalid-return-calldata-address.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | Web3Function.onRun(async () => { 4 | return { 5 | canExec: true, 6 | callData: [ 7 | { 8 | to: "address", 9 | data: "0x0", 10 | }, 11 | ], 12 | }; 13 | }); 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/loader/__test__/just-ts/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | // Return execution call data 8 | return { 9 | canExec: false, 10 | message: "simple-test", 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/lib/loader/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Log } from "@ethersproject/providers"; 2 | 3 | import { Web3FunctionUserArgs } from "../../types"; 4 | 5 | export interface W3fDetails { 6 | path: string; 7 | userArgs: Web3FunctionUserArgs; 8 | storage: { [key: string]: string }; 9 | secrets: { [key: string]: string }; 10 | log?: Log; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/builder/__test__/valid-schema/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | // Return execution call data 8 | return { 9 | canExec: false, 10 | message: "simple-test", 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-os/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | const os = await Deno.osRelease(); 8 | console.log(os); 9 | return { canExec: false, message: "Sandbox escaped os" }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-env/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | const env = Deno.env.toObject(); 8 | console.log(env); 9 | return { canExec: false, message: "Sandbox escaped env" }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/invalid-return-calldata-data.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | Web3Function.onRun(async () => { 4 | return { 5 | canExec: true, 6 | callData: [ 7 | { 8 | to: "0x6a3c82330164822A8a39C7C0224D20DB35DD030a", 9 | data: "data", 10 | }, 11 | ], 12 | }; 13 | }); 14 | -------------------------------------------------------------------------------- /src/web3-functions/fails/blacklisted-host/index.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | import axios from "axios"; 3 | 4 | Web3Function.onRun(async () => { 5 | await axios.get("http://testblacklistedhost.com", { 6 | timeout: 1000, 7 | }); 8 | 9 | return { canExec: false, message: "Accessed to blacklisted url" }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-cpu/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | const proc = Deno.run({ cmd: ["whoami"] }); 8 | console.log(proc); 9 | return { canExec: false, message: "Sandbox escaped cpu" }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-file/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | const conf = Deno.readTextFile("./.env"); 8 | console.log(conf); 9 | return { canExec: false, message: "Sandbox escaped file system" }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/web3-functions/wait.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | const delay = (t: number) => new Promise((resolve) => setTimeout(resolve, t)); 6 | 7 | Web3Function.onRun(async (context: Web3FunctionContext) => { 8 | await delay(5_000); 9 | return { canExec: false, message: "Waiting..." }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "../loader/types"; 2 | export * from "../provider/types"; 3 | export * from "../runtime/types"; 4 | export * from "./Web3FunctionContext"; 5 | export * from "./Web3FunctionEvent"; 6 | export * from "./Web3FunctionOperation"; 7 | export * from "./Web3FunctionResult"; 8 | export * from "./Web3FunctionSchema"; 9 | export * from "./Web3FunctionUserArgs"; 10 | -------------------------------------------------------------------------------- /src/web3-functions/fails/not-registered/index.ts: -------------------------------------------------------------------------------- 1 | import { Web3FunctionContext } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | const main = async (context: Web3FunctionContext) => { 4 | return { canExec: false, message: "Sandbox escaped timeout" }; 5 | }; 6 | 7 | const delay = (time: number) => new Promise((res) => setTimeout(res, time)); 8 | delay(3600_000).then(() => console.log("Not registered")); 9 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | 4 | // Mimic __dirname behavior in ES modules for Jest 5 | const currentFolder = dirname(fileURLToPath(import.meta.url)); 6 | 7 | ///Users/mky/Projects/gelato/w3f/sdk/web3-functions-sdk/src/lib/builder/Web3FunctionBuilder.ts 8 | const __dirname = `${currentFolder}/src/lib/builder/`; 9 | 10 | global.__dirname = __dirname; 11 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-timeout/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | const delay = (time: number) => new Promise((res) => setTimeout(res, time)); 7 | 8 | Web3Function.onRun(async (context: Web3FunctionContext) => { 9 | await delay(3600_000); 10 | return { canExec: false, message: "Sandbox escaped timeout" }; 11 | }); 12 | -------------------------------------------------------------------------------- /src/lib/runtime/__test__/invalid-return-calldata-value.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | 3 | Web3Function.onRun(async () => { 4 | return { 5 | canExec: true, 6 | callData: [ 7 | { 8 | to: "0x6a3c82330164822A8a39C7C0224D20DB35DD030a", 9 | data: "0x00123123133012312313", 10 | value: "non-numeric", 11 | }, 12 | ], 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /src/web3-functions/fails/no-result/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | const delay = (time: number) => new Promise((res) => setTimeout(res, time)); 7 | 8 | Web3Function.onRun(async (_context: Web3FunctionContext) => { 9 | await delay(1000); 10 | Deno.exit(0); 11 | return { canExec: false, message: "Exit before result" }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/web3-functions/fails/request-limit/index.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | import axios from "axios"; 3 | 4 | Web3Function.onRun(async () => { 5 | const totalRequests = Array.from({ length: 111 }, () => 6 | axios.get(`https://zenquotes.io/api/random`) 7 | ); 8 | await Promise.all(totalRequests); 9 | 10 | return { canExec: false, message: "Request limit exceeded" }; 11 | }); 12 | -------------------------------------------------------------------------------- /src/web3-functions/fails/unhandled-exception/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | const delay = (time: number) => new Promise((res) => setTimeout(res, time)); 7 | 8 | Web3Function.onRun(async (_context: Web3FunctionContext) => { 9 | Promise.resolve().then(() => JSON.parse("invalid json")); 10 | await delay(1000); 11 | return { canExec: false, message: "Throw uncaught exception" }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/lib/binaries/schema.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import colors from "colors/safe"; 3 | import { Web3FunctionUploader } from "../uploader"; 4 | 5 | const OK = colors.green("✓"); 6 | export default async function schema() { 7 | const cid = process.argv[3]; 8 | 9 | if (!cid) throw new Error("Web3Function CID missing"); 10 | 11 | const schema = await Web3FunctionUploader.fetchSchema(cid); 12 | console.log(` ${OK} Fetched Web3Function schema: ${JSON.stringify(schema)}`); 13 | } 14 | -------------------------------------------------------------------------------- /src/web3-functions/fails/download-limit/index.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | import axios from "axios"; 3 | 4 | Web3Function.onRun(async () => { 5 | for (let i = 1; i < 50; i++) { 6 | console.log(`Downloading image ${i}...`); 7 | await axios.get( 8 | `https://fastly.picsum.photos/id/268/1200/1200.jpg?hmac=mh1FfYIsXh1ZKye-wikMGAwDuae5WB0JgUK3BESzZA0` 9 | ); 10 | } 11 | return { canExec: false, message: "Download limit exceeded" }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionResult.ts: -------------------------------------------------------------------------------- 1 | export type Web3FunctionResult = Web3FunctionResultV1 | Web3FunctionResultV2; 2 | 3 | export type Web3FunctionResultV1 = 4 | | { canExec: true; callData: string } 5 | | { canExec: false; message: string }; 6 | 7 | export type Web3FunctionResultV2 = 8 | | { canExec: true; callData: Web3FunctionResultCallData[] } 9 | | { canExec: false; message: string }; 10 | 11 | export type Web3FunctionResultCallData = { 12 | to: string; 13 | data: string; 14 | value?: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/hardhat/tasks/deploy.ts: -------------------------------------------------------------------------------- 1 | import { task } from "hardhat/config"; 2 | import deploy from "../../lib/binaries/deploy"; 3 | import { Web3FunctionLoader } from "../../lib/loader"; 4 | 5 | task("w3f-deploy", "Deploys Gelato Web3 Function") 6 | .addPositionalParam( 7 | "name", 8 | "Web3 Function name defined in hardhat config" 9 | ) 10 | .setAction(async (taskArgs, hre) => { 11 | const w3f = Web3FunctionLoader.load(taskArgs.name, hre.config.w3f.rootDir); 12 | 13 | await deploy(w3f.path); 14 | }); 15 | -------------------------------------------------------------------------------- /src/web3-functions/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 4 | 5 | Web3Function.onRun(async () => { 6 | try { 7 | console.log("Sending zenquote API request"); 8 | const res = await axios.get(`https://zenquotes.io/api/random`, { 9 | timeout: 1000, 10 | }); 11 | const quote = res.data[0].q; 12 | return { canExec: false, message: `Zen quote: ${quote}` }; 13 | } catch (err) { 14 | return { canExec: false, message: `Axios error: ${err.message}` }; 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # local env variables 5 | .env 6 | .env.testing 7 | 8 | # hardhat 9 | artifacts 10 | cache 11 | deployments/localhost 12 | 13 | # cache 14 | .eslintcache 15 | 16 | # VS Code 17 | .vscode 18 | 19 | # macOS 20 | .DS_Store 21 | *.icloud 22 | 23 | dist 24 | .idea 25 | 26 | # redis 27 | dump.rdb 28 | 29 | # coverage 30 | coverage 31 | .nyc_output 32 | 33 | # typechain 34 | typechain 35 | 36 | test/contracts/types 37 | src/logs/ 38 | 39 | .eslintcache 40 | 41 | # resolver builds 42 | .tmp 43 | 44 | # version file 45 | src/lib/version.ts -------------------------------------------------------------------------------- /src/hardhat/hre/type-extensions.ts: -------------------------------------------------------------------------------- 1 | import "hardhat/types/config"; 2 | import "hardhat/types/runtime"; 3 | import { W3fUserConfig, W3fHardhatConfig } from "../types"; 4 | 5 | import { W3fHardhatPlugin } from "./W3fHardhatPlugin"; 6 | 7 | declare module "hardhat/types/config" { 8 | export interface HardhatUserConfig { 9 | w3f: Partial; 10 | } 11 | 12 | export interface HardhatConfig { 13 | w3f: W3fHardhatConfig; 14 | } 15 | } 16 | 17 | declare module "hardhat/types/runtime" { 18 | export interface HardhatRuntimeEnvironment { 19 | w3f: W3fHardhatPlugin; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["es2020"], 5 | "noImplicitAny": false, 6 | "allowSyntheticDefaultImports": true, 7 | "downlevelIteration": true, 8 | "skipLibCheck": true, 9 | "moduleResolution": "node", 10 | "pretty": true, 11 | "resolveJsonModule": true, 12 | "typeRoots": ["./node_modules/@types"], 13 | "outDir": "dist", 14 | "rootDir": "src", 15 | "declaration": true, 16 | "useUnknownInCatchVariables": false 17 | }, 18 | "include": ["src/**/*.ts", "src/**/*.d.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2021": true, 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "extends": ["eslint:recommended", "prettier"], 9 | "parserOptions": { 10 | "ecmaVersion": 12 11 | }, 12 | // Typescript config 13 | "overrides": [ 14 | { 15 | "files": ["*.ts"], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { "project": "./tsconfig.json" }, 18 | "plugins": ["@typescript-eslint", "prettier"], 19 | "extends": [ 20 | "eslint:recommended", 21 | "plugin:@typescript-eslint/recommended", 22 | "plugin:@typescript-eslint/eslint-recommended", 23 | "prettier" 24 | ] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/binaries/fetch.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import colors from "colors/safe"; 3 | import { Web3FunctionUploader } from "../uploader"; 4 | 5 | const OK = colors.green("✓"); 6 | export default async function fetch() { 7 | const cid = process.argv[3]; 8 | 9 | if (!cid) throw new Error("Web3Function CID missing"); 10 | 11 | const web3FunctionDir = await Web3FunctionUploader.fetch(cid); 12 | console.log(` ${OK} Fetched Web3Function to: ${web3FunctionDir}`); 13 | 14 | const { schemaPath, web3FunctionPath } = await Web3FunctionUploader.extract( 15 | web3FunctionDir 16 | ); 17 | console.log( 18 | ` ${OK} Extracted Web3Function. \n schemaPath: ${schemaPath} \n web3FunctionPath: ${web3FunctionPath}` 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/binaries/deploy.ts: -------------------------------------------------------------------------------- 1 | import colors from "colors/safe"; 2 | import "dotenv/config"; 3 | import path from "path"; 4 | import { Web3FunctionBuilder } from "../builder"; 5 | 6 | const OK = colors.green("✓"); 7 | const web3FunctionSrcPath = 8 | process.argv[3] ?? 9 | path.join(process.cwd(), "src", "web3-functions", "index.ts"); 10 | 11 | export default async function deploy(w3fPath?: string) { 12 | const cid = await Web3FunctionBuilder.deploy(w3fPath ?? web3FunctionSrcPath); 13 | console.log(` ${OK} Web3Function deployed to ipfs.`); 14 | console.log(` ${OK} CID: ${cid}`); 15 | console.log( 16 | `\nTo create a task that runs your Web3 Function every minute, visit:` 17 | ); 18 | console.log(`> https://app.gelato.network/new-task?cid=${cid}`); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionSchema.ts: -------------------------------------------------------------------------------- 1 | export enum Web3FunctionVersion { 2 | V1_0_0 = "1.0.0", 3 | V2_0_0 = "2.0.0", 4 | } 5 | 6 | export enum Web3FunctionExecutionMode { 7 | SEQUENTIAL = "sequential", 8 | PARALLEL = "parallel", 9 | } 10 | 11 | export interface Web3FunctionSchema { 12 | web3FunctionVersion: Web3FunctionVersion; 13 | runtime: string; 14 | memory: number; 15 | timeout: number; 16 | userArgs: Web3FunctionUserArgsSchema; 17 | executionMode?: Web3FunctionExecutionMode; 18 | eventRetryInterval?: number; 19 | eventRetryTtl?: number; 20 | } 21 | 22 | export interface Web3FunctionUserArgsSchema { 23 | [key: string]: 24 | | "string" 25 | | "number" 26 | | "boolean" 27 | | "string[]" 28 | | "number[]" 29 | | "boolean[]"; 30 | } 31 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-storage/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | function generateByteString(n: number): string { 7 | console.log("Generating byte string...", n); 8 | const encoder = new TextEncoder(); 9 | const byteBuffer = new Uint8Array(n); 10 | byteBuffer.fill(encoder.encode("x")); 11 | return new TextDecoder().decode(byteBuffer); 12 | } 13 | 14 | Web3Function.onRun(async (context: Web3FunctionContext) => { 15 | const { storage } = context; 16 | 17 | const randomMessage = generateByteString(10 * 1024); 18 | 19 | await storage.set("myLastMessage", randomMessage); 20 | 21 | return { 22 | canExec: false, 23 | message: "Updated message", 24 | }; 25 | }); 26 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3FunctionContext, 3 | Web3FunctionEventContext, 4 | Web3FunctionFailContext, 5 | Web3FunctionSuccessContext, 6 | } from "./Web3FunctionContext"; 7 | import { Web3FunctionResult } from "./Web3FunctionResult"; 8 | 9 | export type BaseRunHandler = ( 10 | ctx: Web3FunctionContext 11 | ) => Promise; 12 | export type EventRunHandler = ( 13 | ctx: Web3FunctionEventContext 14 | ) => Promise; 15 | export type RunHandler = BaseRunHandler | EventRunHandler; 16 | 17 | type BaseFailHandler = (ctx: Web3FunctionFailContext) => Promise; 18 | export type FailHandler = BaseFailHandler; 19 | 20 | type BaseSuccessHandler = (ctx: Web3FunctionSuccessContext) => Promise; 21 | export type SuccessHandler = BaseSuccessHandler; 22 | -------------------------------------------------------------------------------- /src/web3-functions/fails/escape-memory/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | const arr: string[] = []; 8 | while (arr.length < 1_000_000_000) { 9 | arr.push(`Do we have access to infinite memory? 10 | Do we have access to infinite memory? 11 | Do we have access to infinite memory? 12 | Do we have access to infinite memory? 13 | Do we have access to infinite memory? 14 | Do we have access to infinite memory? 15 | Do we have access to infinite memory? 16 | Do we have access to infinite memory? 17 | Do we have access to infinite memory? 18 | Do we have access to infinite memory?`); 19 | } 20 | return { canExec: false, message: "Sandbox escaped Memory" }; 21 | }); 22 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | module: "commonjs", 5 | transform: {}, 6 | collectCoverage: true, 7 | collectCoverageFrom: ["src/**/*.ts"], 8 | coveragePathIgnorePatterns: [ 9 | "node_modules", 10 | "jest.config.ts", 11 | "src/web3-functions", 12 | "lib/Web3Function.ts", 13 | "lib/polyfill", 14 | "lib/binaries", 15 | "bin", 16 | "hardhat", 17 | ".d.ts", 18 | "__test__", 19 | ], 20 | testPathIgnorePatterns: ["node_modules", "dist", "lib/binaries"], 21 | extensionsToTreatAsEsm: [".ts"], 22 | coverageReporters: ["lcov", "json", "html"], 23 | globals: { 24 | "ts-jest": { 25 | useESM: true, 26 | }, 27 | }, 28 | moduleNameMapper: { 29 | "^(\\.{1,2}/.*)\\.js$": "$1", 30 | }, 31 | setupFilesAfterEnv: ["./jest.setup.ts"], 32 | }; 33 | -------------------------------------------------------------------------------- /src/web3-functions/fails/upload-limit/index.ts: -------------------------------------------------------------------------------- 1 | import { Web3Function } from "@gelatonetwork/web3-functions-sdk"; 2 | import axios from "axios"; 3 | 4 | Web3Function.onRun(async () => { 5 | const imageUrl = `https://fastly.picsum.photos/id/268/1200/1200.jpg?hmac=mh1FfYIsXh1ZKye-wikMGAwDuae5WB0JgUK3BESzZA0`; 6 | const imageBlob = (await axios.get(imageUrl, { responseType: "blob" })).data; 7 | 8 | const webhookUuid = ( 9 | await axios.post<{ uuid: string }>(`https://webhook.site/token`) 10 | ).data.uuid; 11 | 12 | for (let i = 1; i <= 50; i++) { 13 | console.log(`Uploading image ${i}...`); 14 | const postUrl = `https://webhook.site/${webhookUuid}?i=${i}`; 15 | await axios.post(postUrl, imageBlob, { 16 | headers: { "Content-Type": "image/jpeg" }, 17 | }); 18 | } 19 | 20 | return { canExec: false, message: "Upload limit exceeded" }; 21 | }); 22 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=gelato-network_web3-functions-sdk 2 | sonar.organization=gelato-network 3 | 4 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 5 | 6 | sonar.qualitygate.wait=true 7 | 8 | sonar.exclusions=**/*spec.ts,src/lib/polyfill/*,jest.config.ts,src/lib/binaries/*,**/__test__/**/* 9 | sonar.coverage.exclusions=**/*spec.ts,src/lib/polyfill/*,jest.config.ts,src/lib/binaries/*,**/__test__/**/* 10 | sonar.cpd.exclusions=**/*spec.ts,src/lib/polyfill/*,jest.config.ts,src/lib/binaries/*,**/__test__/**/* 11 | 12 | # This is the name and version displayed in the SonarCloud UI. 13 | #sonar.projectName=web3-functions-sdk 14 | #sonar.projectVersion=1.0 15 | 16 | 17 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 18 | sonar.sources=src/lib/ 19 | sonar.tests=src/web3-functions/ 20 | 21 | # Encoding of the source code. Default is default system encoding 22 | #sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /src/web3-functions/event/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionEventContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | import { Interface } from "@ethersproject/abi"; 7 | 8 | const abi = [ 9 | `event ExecSuccess( 10 | uint256 indexed txFee, 11 | address indexed feeToken, 12 | address indexed execAddress, 13 | bytes execData, 14 | bytes32 taskId, 15 | bool callSuccess 16 | )`, 17 | ]; 18 | 19 | Web3Function.onRun(async (context: Web3FunctionEventContext) => { 20 | const { log } = context; 21 | 22 | if (log.blockNumber !== 9727562) { 23 | throw new Error("Log file fetched invalid"); 24 | } 25 | 26 | const contractInterface = new Interface(abi); 27 | 28 | const description = contractInterface.parseLog(log); 29 | 30 | if (description.name !== "ExecSuccess") { 31 | throw new Error("Log is unexpected"); 32 | } 33 | 34 | return { 35 | canExec: false, 36 | message: `Log fetched from file`, 37 | }; 38 | }); 39 | -------------------------------------------------------------------------------- /.github/workflows/test-scan.yml: -------------------------------------------------------------------------------- 1 | name: test-scan 2 | on: push 3 | 4 | jobs: 5 | sonarcloud_unit-tests: 6 | name: sonarcloud_unit-tests 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | fetch-depth: 0 12 | 13 | - name: Install 14 | run: yarn install --frozen-lockfile 15 | 16 | - name: Build 17 | run: yarn build 18 | 19 | - name: Unit test 20 | run: yarn test:unit 21 | 22 | - name: Cleanup for SonarCloud 23 | run: rm -rf ./node_modules .github .vscode .tmp 24 | 25 | - name: Upload coverage 26 | uses: actions/upload-artifact@v3 27 | with: 28 | name: test-coverage 29 | path: ${{ github.workspace }}/coverage 30 | retention-days: 2 31 | 32 | - name: SonarCloud Scan 33 | uses: SonarSource/sonarcloud-github-action@master 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 36 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 37 | -------------------------------------------------------------------------------- /src/lib/runtime/types/Web3FunctionRunnerPayload.ts: -------------------------------------------------------------------------------- 1 | import { MultiChainProviderConfig } from "../../provider"; 2 | import { Web3FunctionOperation, Web3FunctionVersion } from "../../types"; 3 | import { Web3FunctionContextData } from "../../types/Web3FunctionContext"; 4 | 5 | export interface Web3FunctionRunnerOptions { 6 | memory: number; 7 | timeout: number; 8 | rpcLimit: number; 9 | downloadLimit: number; 10 | uploadLimit: number; 11 | requestLimit: number; 12 | storageLimit: number; 13 | runtime: "thread" | "docker"; 14 | showLogs: boolean; 15 | httpProxyPort?: number; 16 | rpcProxyPort?: number; 17 | serverPort?: number; 18 | blacklistedHosts?: string[]; 19 | } 20 | 21 | export interface Web3FunctionRunnerPayload { 22 | script: string; 23 | context: Web3FunctionContextData; 24 | options: Web3FunctionRunnerOptions; 25 | multiChainProviderConfig: MultiChainProviderConfig; 26 | version: Web3FunctionVersion; 27 | } 28 | 29 | export type Web3FunctionRunnerPayloadAny = 30 | Web3FunctionRunnerPayload; 31 | -------------------------------------------------------------------------------- /src/web3-functions/event/log.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockNumber": 9727562, 3 | "blockHash": "0xd85e45fb419a05851e0edc62f9dfececdca11166ffe41eb6c4c9a97ddc1ac38f", 4 | "transactionIndex": 60, 5 | "removed": false, 6 | "address": "0xc1C6805B857Bef1f412519C4A842522431aFed39", 7 | "data": "0x0000000000000000000000000000000000000000000000000000000000000060301ca244429ceca7bb205a7235e2ec41472943010b182ec32d342d2426dabb83000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000242a3220e6000000000000000000000000000000000000000000000000000000000000012e00000000000000000000000000000000000000000000000000000000", 8 | "topics": [ 9 | "0xa458375b1282695a972870cbfbc4891a9d856b79d563d17667d171d87e0c527a", 10 | "0x0000000000000000000000000000000000000000000000000000000001808da6", 11 | "0x000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 12 | "0x000000000000000000000000d808604a1a4163b086fcf1d06b0b4d79b2eedff5" 13 | ], 14 | "transactionHash": "0x5440bc3b1894a5ec4e644509bcd09102d2fec63d6bd4c55c8ec47585170a0e6d", 15 | "logIndex": 200 16 | } 17 | -------------------------------------------------------------------------------- /src/web3-functions/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | Web3Function.onRun(async (context: Web3FunctionContext) => { 7 | const { storage, multiChainProvider } = context; 8 | 9 | const provider = multiChainProvider.default(); 10 | 11 | // Use storage to manage your execution state 12 | const lastBlockStr = (await storage.get("lastBlockNumber")) ?? "0"; 13 | 14 | // Stored values are always in string 15 | const lastBlock = parseInt(lastBlockStr); 16 | console.log(`Last block: ${lastBlock}`); 17 | 18 | const newBlock = await provider.getBlockNumber(); 19 | console.log(`New block: ${newBlock}`); 20 | if (newBlock > lastBlock) { 21 | // Cast value to string before storing it 22 | await storage.set("lastBlockNumber", newBlock.toString()); 23 | } 24 | 25 | console.log(`Storage Keys: ${await storage.getKeys()}`); 26 | console.log(`Storage Size: ${await storage.getSize()}`); 27 | 28 | return { 29 | canExec: false, 30 | message: `Updated block number: ${newBlock.toString()}`, 31 | }; 32 | }); 33 | -------------------------------------------------------------------------------- /src/web3-functions/fails/standard-plan-warning/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | const delay = (time: number) => new Promise((res) => setTimeout(res, time)); 7 | 8 | const ORACLE_ABI = [ 9 | "function lastUpdated() external view returns(uint256)", 10 | "function updatePrice(uint256)", 11 | ]; 12 | 13 | Web3Function.onRun(async (context: Web3FunctionContext) => { 14 | const { multiChainProvider } = context; 15 | 16 | const provider = multiChainProvider.default(); 17 | 18 | // Test soft rate limits 19 | const oracleAddress = "0x6a3c82330164822A8a39C7C0224D20DB35DD030a"; 20 | const oracle = new Contract(oracleAddress, ORACLE_ABI, provider); 21 | try { 22 | const promises: Promise[] = []; 23 | for (let i = 0; i < 20; i++) promises.push(oracle.lastUpdated()); 24 | await Promise.race(promises); 25 | } catch (err) { 26 | console.log("Throttling RPC calls error:", err.message); 27 | } 28 | 29 | await delay(9000); 30 | 31 | return { canExec: false, message: "RPC providers tests ok!" }; 32 | }); 33 | -------------------------------------------------------------------------------- /src/lib/builder/web3function.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Web3 Function Schema", 3 | "description": "Web3 Function Schema", 4 | "type": "object", 5 | "properties": { 6 | "web3FunctionVersion": { 7 | "enum": ["2.0.0"] 8 | }, 9 | "runtime": { 10 | "enum": ["js-1.0"] 11 | }, 12 | "memory": { 13 | "enum": [128, 256, 512] 14 | }, 15 | "timeout": { 16 | "type": "number", 17 | "minimum": 5, 18 | "maximum": 300 19 | }, 20 | "executionMode": { 21 | "enum": ["sequential", "parallel"] 22 | }, 23 | "eventRetryInterval": { 24 | "type": "number", 25 | "minimum": 60, 26 | "maximum": 60 27 | }, 28 | "eventRetryTtl": { 29 | "type": "number", 30 | "minimum": 0, 31 | "maximum": 259200 32 | }, 33 | "userArgs": { 34 | "type": "object", 35 | "patternProperties": { 36 | "^[\\w\\-]+$": { 37 | "enum": [ 38 | "string", 39 | "number", 40 | "boolean", 41 | "string[]", 42 | "number[]", 43 | "boolean[]" 44 | ] 45 | } 46 | }, 47 | "additionalProperties": false 48 | } 49 | }, 50 | "required": [ 51 | "web3FunctionVersion", 52 | "runtime", 53 | "memory", 54 | "timeout", 55 | "userArgs" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionEvent.ts: -------------------------------------------------------------------------------- 1 | import { Web3FunctionContextData } from "./Web3FunctionContext"; 2 | import { Web3FunctionResult } from "./Web3FunctionResult"; 3 | 4 | export type Web3FunctionEvent = 5 | | { 6 | action: "start"; 7 | data: 8 | | { 9 | operation: "onRun"; 10 | context: Web3FunctionContextData<"onRun">; 11 | } 12 | | { 13 | operation: "onFail"; 14 | context: Web3FunctionContextData<"onFail">; 15 | } 16 | | { 17 | operation: "onSuccess"; 18 | context: Web3FunctionContextData<"onSuccess">; 19 | }; 20 | } 21 | | { 22 | action: "error"; 23 | data: { 24 | error: Error; 25 | storage: Web3FunctionStorage; 26 | callbacks: Web3FunctionCallbackStatus; 27 | }; 28 | } 29 | | { 30 | action: "result"; 31 | data: { 32 | result: Web3FunctionResult | undefined; 33 | storage: Web3FunctionStorage; 34 | callbacks: Web3FunctionCallbackStatus; 35 | }; 36 | }; 37 | 38 | export type Web3FunctionCallbackStatus = { 39 | onSuccess: boolean; 40 | onFail: boolean; 41 | }; 42 | 43 | export type Web3FunctionStorage = { 44 | state: "updated" | "last"; 45 | storage: { [key: string]: string | undefined }; 46 | diff: object; 47 | }; 48 | 49 | export type Web3FunctionStorageWithSize = Web3FunctionStorage & { 50 | size: number; 51 | }; 52 | -------------------------------------------------------------------------------- /src/web3-functions/callbacks/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | Web3FunctionFailContext, 5 | Web3FunctionSuccessContext, 6 | } from "@gelatonetwork/web3-functions-sdk"; 7 | 8 | const ORACLE_ABI = [ 9 | "function lastUpdated() external view returns(uint256)", 10 | "function updatePrice(uint256)", 11 | ]; 12 | 13 | Web3Function.onRun(async (context: Web3FunctionContext) => { 14 | const { userArgs, multiChainProvider } = context; 15 | 16 | const provider = multiChainProvider.default(); 17 | 18 | const canExec = Boolean(userArgs.canExec); 19 | 20 | if (canExec) { 21 | return { canExec, callData: "0x00000000" }; 22 | } else { 23 | return { canExec, message: "onRun" }; 24 | } 25 | }); 26 | 27 | Web3Function.onFail(async (context: Web3FunctionFailContext) => { 28 | const { userArgs, reason } = context; 29 | 30 | console.log("userArgs: ", userArgs.canExec); 31 | 32 | if (reason === "ExecutionReverted") { 33 | console.log(`onFail: ${reason} txHash: ${context.transactionHash}`); 34 | } else if (reason === "SimulationFailed") { 35 | console.log( 36 | `onFail: ${reason} callData: ${JSON.stringify(context.callData)}` 37 | ); 38 | } else { 39 | console.log(`onFail: ${reason}`); 40 | } 41 | }); 42 | 43 | Web3Function.onSuccess(async (context: Web3FunctionSuccessContext) => { 44 | const { userArgs, transactionHash } = context; 45 | 46 | console.log("userArgs: ", userArgs.canExec); 47 | console.log("onSuccess: txHash: ", transactionHash); 48 | }); 49 | -------------------------------------------------------------------------------- /src/web3-functions/custom-error/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | 6 | import { Contract } from "@ethersproject/contracts"; 7 | 8 | export const abi = [ 9 | { 10 | inputs: [{ internalType: "uint256", name: "random", type: "uint256" }], 11 | name: "CustomError", 12 | type: "error", 13 | }, 14 | { 15 | inputs: [{ internalType: "uint256", name: "_param", type: "uint256" }], 16 | name: "throwCustom", 17 | outputs: [], 18 | stateMutability: "nonpayable", 19 | type: "function", 20 | }, 21 | ]; 22 | 23 | // To run against Arbitrum Sepolia: 24 | // yarn test src/web3-functions/custom-error/index.ts --logs --chain-id=421614 25 | 26 | Web3Function.onRun(async (context: Web3FunctionContext) => { 27 | const ERRORS_CONTRACT = "0xac9f91277cCbb5d270e27246b203B221023A0e06"; 28 | 29 | const { multiChainProvider } = context; 30 | let provider = multiChainProvider.default(); 31 | 32 | const errorContract = new Contract(ERRORS_CONTRACT, abi, provider); 33 | 34 | try { 35 | const res = await errorContract.callStatic.throwCustom(10); 36 | } catch (error: any) { 37 | console.log("error message ", error.message); 38 | console.log("error data: ", error.data); 39 | const ei = errorContract.interface.parseError(error.data); 40 | console.log("error name: ", ei.name); 41 | console.log("error args: ", ei.args); 42 | } 43 | 44 | return { 45 | canExec: false, 46 | message: "executed", 47 | }; 48 | }); 49 | -------------------------------------------------------------------------------- /src/lib/deno.d.ts: -------------------------------------------------------------------------------- 1 | // Minimalistic deno global types declaration 2 | declare global { 3 | const window: any; 4 | const Deno: { 5 | env: { get: (key: string) => string; toObject: () => any }; 6 | exit: (code: number) => void; 7 | serve: (options: any) => any; 8 | run: (opt: { cmd: string[] }) => any; 9 | readTextFile: (file: string) => string; 10 | osRelease: () => any; 11 | listen: (options: any & { transport?: "tcp" }) => any; 12 | serveHttp: (conn: any) => any; 13 | }; 14 | const fetch: any; 15 | class Response { 16 | constructor(s: string, options?: { status: number }); 17 | } 18 | class TextEncoder { 19 | encode(s: string): Uint8Array; 20 | } 21 | class Request { 22 | constructor(e: string, o: any); 23 | method: string; 24 | url: string; 25 | json: () => any; 26 | body: any; 27 | } 28 | class EventTarget { 29 | dispatchEvent(evt: ProgressEvent); 30 | } 31 | class Event { 32 | constructor(e: string); 33 | type: any; 34 | cancelable: any; 35 | defaultPrevented: any; 36 | } 37 | class ProgressEvent { 38 | constructor(e: string, o: { loaded: any; total: any }); 39 | type: any; 40 | } 41 | class DOMException { 42 | constructor(e: string, f: string); 43 | } 44 | class Blob { 45 | constructor(e: Uint8Array[], o: any); 46 | } 47 | class Headers { 48 | append(s: string, v: string); 49 | } 50 | class TextDecoder { 51 | constructor(s?: string); 52 | decode(s: Uint8Array): string; 53 | } 54 | } 55 | export {}; 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @gelatonetwork/web3-functions-sdk 2 | 3 | ## 2.4.4 4 | 5 | ### Patch Changes 6 | 7 | - a0f32bf: fix: start failures edge cases 8 | 9 | ## 2.4.2 10 | 11 | ### Patch Changes 12 | 13 | - b7f608b: fix: http proxy query parameters 14 | 15 | ## 2.4.1 16 | 17 | ### Patch Changes 18 | 19 | - caa853d: chore: use sepolia instead of goerli as default testnet 20 | - 4375479: chore: bump axios 1.6.8 21 | - f62dd06: fix: keep proxy provider original error 22 | 23 | ## 2.4.0 24 | 25 | ### Minor Changes 26 | 27 | - d9834fe: chore: add eventRetry schema changes 28 | - feat: add eventRetry schema 29 | 30 | ## 2.3.1 31 | 32 | ### Patch Changes 33 | 34 | - chore: improve startup connection 35 | 36 | ## 2.3.0 37 | 38 | ### Minor Changes 39 | 40 | - 3fe7d8f: chore: support node v20 and bump required node v18 41 | 42 | ## 2.2.3 43 | 44 | ### Patch Changes 45 | 46 | - 19d6d0c: chore: add storage helper functions 47 | - f58f375: chore: increase resource limits 48 | - 19d6d0c: chore: improve unhandled promise rejection 49 | 50 | ## 2.2.1 51 | 52 | ### Patch Changes 53 | 54 | - 88cef0d: fix: invoke fail callback on insufficient funds 55 | 56 | ## 2.2.0 57 | 58 | ### Minor Changes 59 | 60 | - 8c757a5: feat: callback support 61 | 62 | ### Patch Changes 63 | 64 | - 9af7269: fix: support axios imports before lib import 65 | - a09c9a1: chore: get additional backup ports for runner 66 | - 166e091: fix: make sandbox testable 67 | 68 | ## 2.1.8 69 | 70 | ### Patch Changes 71 | 72 | - 720ceae: chore: try longest available port first 73 | - e3c6a21: refactor: code quality improvements 74 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## Managing versions 4 | 5 | After your changes on a specific branch is complete explain your changes in the changeset, to add a changeset utilize 6 | the following command within your CLI; 7 | 8 | ``` 9 | $ yarn changeset 10 | ``` 11 | 12 | according to your code changes specifc either patch, minor or major. This selection will effect the versioning; 13 | 14 | ``` 15 | major.minor.patch --> 0.0.2 16 | ``` 17 | 18 | ⚠️ Always include a changeset with your PRs 19 | 20 | ## Creating a release 21 | 22 | Releases should require an intent from developer, thus if a new release is required to be created utilize 23 | 24 | ``` 25 | yarn changeset version 26 | ``` 27 | 28 | command, this will create a CHANGELOG file and update the version according to the changesets. All previous changesets will be removed and included with this release. 29 | 30 | ## Publishing the release 31 | 32 | You must create a release before publishing it, i.e. utilize `yarn changeset version` command beforehand! 33 | 34 | ``` 35 | yarn changeset publish 36 | ``` 37 | 38 | to publish the release. Publish operations will require OTP. 39 | 40 | ‼️ For **pre-release** versions specify `--tag {tagname}`. 41 | 42 | The versioning will work like this; 43 | 44 | 1. Create collection of PR changes with changesets included 45 | 2. Merge those changes, then create a version PR with `yarn changeset version`, utilize `git push --follow-tags` 46 | 3. Deploy your release to a beta, pre-release with `yarn changeset publish --tag betatest` 47 | 4. Test your release then publish it to `latest` with `yarn changeset publish` 48 | -------------------------------------------------------------------------------- /src/lib/loader/Web3FunctionLoader.spec.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { Web3FunctionLoader } from "./Web3FunctionLoader"; 3 | 4 | describe("Web3FunctionLoader.load", () => { 5 | const TEST_FOLDER_BASE = path.join(process.cwd(), "src/lib/loader/__test__/"); 6 | 7 | test("should fail when source is missing", () => { 8 | try { 9 | Web3FunctionLoader.load("source-missing", TEST_FOLDER_BASE); 10 | throw Error("Test failed"); 11 | } catch (error) { 12 | expect(error.message.includes("not found")).toBeTruthy(); 13 | } 14 | }); 15 | 16 | test("should load when TS source is available", () => { 17 | const details = Web3FunctionLoader.load("just-ts", TEST_FOLDER_BASE); 18 | 19 | expect(details.path.includes("just-ts/index.ts")).toBeTruthy(); 20 | }); 21 | 22 | test("should load when JS source is available", () => { 23 | const details = Web3FunctionLoader.load("just-js", TEST_FOLDER_BASE); 24 | 25 | expect(details.path.includes("just-js/index.js")).toBeTruthy(); 26 | }); 27 | 28 | test("should load userArgs file when is available", () => { 29 | const details = Web3FunctionLoader.load("just-ts", TEST_FOLDER_BASE); 30 | 31 | expect(details.userArgs).toHaveProperty("currency"); 32 | }); 33 | 34 | test("should load storage file when is available", () => { 35 | const details = Web3FunctionLoader.load("just-ts", TEST_FOLDER_BASE); 36 | 37 | expect(details.storage).toHaveProperty("lastBlockNumber"); 38 | }); 39 | 40 | test("should load secrets when is available", () => { 41 | const details = Web3FunctionLoader.load("just-ts", TEST_FOLDER_BASE); 42 | 43 | expect(details.secrets).toHaveProperty("COINGECKO_API"); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/hardhat/tasks/run.ts: -------------------------------------------------------------------------------- 1 | import { task } from "hardhat/config"; 2 | import test, { CallConfig } from "../../lib/binaries/test"; 3 | import { Web3FunctionLoader } from "../../lib/loader"; 4 | import { 5 | EthersProviderWrapper, 6 | getMultiChainProviderConfigs, 7 | } from "../provider"; 8 | 9 | task("w3f-run", "Runs Gelato Web3 Function") 10 | .addFlag("debug", "Enable debug mode") 11 | .addFlag("logs", "Show Web3 Function logs") 12 | .addFlag("onfail", "Runs onFail callback") 13 | .addFlag("onsuccess", "Runs onSuccess callback") 14 | .addPositionalParam( 15 | "name", 16 | "Web3 Function name defined in hardhat config" 17 | ) 18 | .setAction(async (taskArgs, hre) => { 19 | const w3f = Web3FunctionLoader.load(taskArgs.name, hre.config.w3f.rootDir); 20 | 21 | const provider = new EthersProviderWrapper(hre.network.provider); 22 | 23 | const debug = taskArgs.debug ?? hre.config.w3f.debug; 24 | 25 | const onFail = taskArgs.onfail ?? false; 26 | const onSuccess = taskArgs.onsuccess ?? false; 27 | const operation = onFail ? "onFail" : onSuccess ? "onSuccess" : "onRun"; 28 | 29 | const chainId = 30 | hre.network.config.chainId ?? (await provider.getNetwork()).chainId; 31 | 32 | const multiChainProviderConfig = await getMultiChainProviderConfigs(hre); 33 | 34 | const callConfig: Partial = { 35 | operation, 36 | w3fPath: w3f.path, 37 | debug, 38 | showLogs: taskArgs.logs, 39 | runtime: "thread", 40 | userArgs: w3f.userArgs, 41 | storage: w3f.storage, 42 | secrets: w3f.secrets, 43 | multiChainProviderConfig, 44 | chainId, 45 | log: w3f.log, 46 | }; 47 | 48 | await test(callConfig); 49 | }); 50 | -------------------------------------------------------------------------------- /src/lib/net/Web3FunctionNetHelper.ts: -------------------------------------------------------------------------------- 1 | import net from "net"; 2 | 3 | export class Web3FunctionNetHelper { 4 | public static getAvailablePort( 5 | occupiedPorts: number[] = [] 6 | ): Promise { 7 | return new Promise((resolve, reject) => { 8 | const srv = net.createServer(); 9 | srv.listen(0, async () => { 10 | const address = srv.address(); 11 | const port = address && typeof address === "object" ? address.port : -1; 12 | srv.close(async () => { 13 | if (port === -1) { 14 | reject(new Error("Failed to get a port.")); 15 | return; 16 | } 17 | 18 | if (occupiedPorts.includes(port)) { 19 | try { 20 | const newPort = await Web3FunctionNetHelper.getAvailablePort( 21 | occupiedPorts 22 | ); 23 | resolve(newPort); 24 | } catch (error) { 25 | reject(error); 26 | } 27 | } else { 28 | resolve(port); 29 | } 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | public static async getAvailablePorts(size: number): Promise { 36 | const ports: number[] = []; 37 | let retries = 0; 38 | const maxRetries = size * 2; 39 | while (ports.length < size) { 40 | const port = await Web3FunctionNetHelper.getAvailablePort(); 41 | if (ports.includes(port)) { 42 | retries++; 43 | if (retries === maxRetries) { 44 | throw new Error( 45 | `Web3FunctionNetHelper Error: Unable to get ${size} free ports` 46 | ); 47 | } 48 | } else { 49 | ports.push(port); 50 | } 51 | } 52 | return ports; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/hardhat/hre/index.ts: -------------------------------------------------------------------------------- 1 | import { extendConfig, extendEnvironment } from "hardhat/config"; 2 | import { lazyObject } from "hardhat/plugins"; 3 | import { HardhatConfig, HardhatUserConfig } from "hardhat/types"; 4 | import path from "path"; 5 | 6 | import { W3f_ROOT_DIR } from "../constants/index"; 7 | import { W3fHardhatPlugin } from "./W3fHardhatPlugin"; 8 | 9 | // This import is needed to let the TypeScript compiler know that it should include your type 10 | // extensions in your npm package's types file. 11 | import "./type-extensions"; 12 | 13 | extendConfig( 14 | (config: HardhatConfig, userConfig: Readonly) => { 15 | // set up root dir 16 | const usrRootDir = userConfig.w3f.rootDir; 17 | 18 | let w3fRootDir: string; 19 | if (usrRootDir === undefined) { 20 | w3fRootDir = path.join(config.paths.root, W3f_ROOT_DIR); 21 | } else if (path.isAbsolute(usrRootDir)) { 22 | w3fRootDir = usrRootDir; 23 | } else { 24 | w3fRootDir = path.normalize(path.join(config.paths.root, usrRootDir)); 25 | } 26 | 27 | config.w3f.rootDir = w3fRootDir; 28 | 29 | // set up w3f networks for multi chain provider 30 | const usrW3fNetworks = userConfig.w3f.networks; 31 | const defaultNetwork = 32 | userConfig.defaultNetwork ?? config.defaultNetwork ?? "hardhat"; 33 | 34 | let networks: Set; 35 | if (!usrW3fNetworks || usrW3fNetworks.length === 0) { 36 | networks = new Set(defaultNetwork); 37 | } else { 38 | networks = new Set([...usrW3fNetworks, defaultNetwork]); 39 | } 40 | 41 | config.w3f.networks = Array.from(networks); 42 | } 43 | ); 44 | 45 | extendEnvironment((hre) => { 46 | // We add a field to the Hardhat Runtime Environment here. 47 | // We use lazyObject to avoid initializing things until they are actually 48 | // needed. 49 | hre.w3f = lazyObject(() => new W3fHardhatPlugin(hre)); 50 | }); 51 | -------------------------------------------------------------------------------- /src/lib/provider/Web3FunctionMultiChainProvider.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | 3 | export class Web3FunctionMultiChainProvider { 4 | private _proxyRpcUrlBase: string; 5 | private _rateLimitCallback: () => void; 6 | private _providers: Map; 7 | private _defaultProvider: StaticJsonRpcProvider; 8 | 9 | constructor( 10 | proxyRpcUrlBase: string, 11 | defaultChainId: number, 12 | rateLimitCallBack: () => void 13 | ) { 14 | this._proxyRpcUrlBase = proxyRpcUrlBase; 15 | this._rateLimitCallback = rateLimitCallBack; 16 | this._providers = new Map(); 17 | this._defaultProvider = new StaticJsonRpcProvider( 18 | proxyRpcUrlBase, 19 | defaultChainId 20 | ); 21 | this._providers.set(defaultChainId, this._defaultProvider); 22 | this._subscribeProviderEvents(this._defaultProvider); 23 | } 24 | 25 | public default(): StaticJsonRpcProvider { 26 | return this._defaultProvider; 27 | } 28 | 29 | public chainId(chainId: number): StaticJsonRpcProvider { 30 | return this._getProviderOfChainId(chainId); 31 | } 32 | 33 | public async nbRpcCallsRemaining() { 34 | const { nbRpcCallsRemaining } = await this._defaultProvider.send( 35 | "nbRpcCallsRemaining", 36 | [] 37 | ); 38 | return nbRpcCallsRemaining; 39 | } 40 | 41 | private _getProviderOfChainId(chainId: number) { 42 | let provider = this._providers.get(chainId); 43 | 44 | if (!provider) { 45 | provider = new StaticJsonRpcProvider( 46 | `${this._proxyRpcUrlBase}/${chainId}`, 47 | chainId 48 | ); 49 | 50 | this._subscribeProviderEvents(provider); 51 | this._providers.set(chainId, provider); 52 | } 53 | 54 | return provider; 55 | } 56 | 57 | private _subscribeProviderEvents(provider: StaticJsonRpcProvider) { 58 | provider.on("debug", (data) => { 59 | if (data.action === "response" && data.error) { 60 | if (/Request limit exceeded/.test(data.error.message)) { 61 | console.error("Web3FunctionError: RPC requests limit exceeded"); 62 | this._rateLimitCallback(); 63 | } 64 | } 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/web3-functions/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | import ky from "ky"; // we recommend using ky as axios doesn't support fetch by default 7 | 8 | const ORACLE_ABI = [ 9 | "function lastUpdated() external view returns(uint256)", 10 | "function updatePrice(uint256)", 11 | ]; 12 | 13 | Web3Function.onRun(async (context: Web3FunctionContext) => { 14 | const { multiChainProvider } = context; 15 | 16 | const provider = multiChainProvider.default(); 17 | 18 | // Retrieve Last oracle update time 19 | let lastUpdated; 20 | let oracle; 21 | const oracleAddress = "0x71B9B0F6C999CBbB0FeF9c92B80D54e4973214da"; 22 | 23 | try { 24 | oracle = new Contract(oracleAddress, ORACLE_ABI, provider); 25 | lastUpdated = parseInt(await oracle.lastUpdated()); 26 | console.log(`Last oracle update: ${lastUpdated}`); 27 | } catch (err) { 28 | return { canExec: false, message: `Rpc call failed` }; 29 | } 30 | 31 | // Check if it's ready for a new update 32 | const nextUpdateTime = lastUpdated + 300; // 5 min 33 | const timestamp = (await provider.getBlock("latest")).timestamp; 34 | console.log(`Next oracle update: ${nextUpdateTime}`); 35 | if (timestamp < nextUpdateTime) { 36 | return { canExec: false, message: `Time not elapsed` }; 37 | } 38 | 39 | // Get current price on coingecko 40 | const currency = "ethereum"; 41 | let price = 0; 42 | try { 43 | const priceData: any = await ky 44 | .get( 45 | `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=usd`, 46 | { timeout: 5_000, retry: 0 } 47 | ) 48 | .json(); 49 | price = Math.floor(priceData[currency].usd); 50 | } catch (err) { 51 | console.log(`Coingecko call failed: ${err.message}`); 52 | return { canExec: false, message: `Coingecko call failed: ${err.message}` }; 53 | } 54 | console.log(`Updating price: ${price}`); 55 | 56 | // Return execution call data 57 | return { 58 | canExec: true, 59 | callData: [ 60 | { 61 | to: oracleAddress, 62 | data: oracle.interface.encodeFunctionData("updatePrice", [price]), 63 | }, 64 | ], 65 | }; 66 | }); 67 | -------------------------------------------------------------------------------- /src/web3-functions/oracle/index.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "@ethersproject/contracts"; 2 | import { 3 | Web3Function, 4 | Web3FunctionContext, 5 | } from "@gelatonetwork/web3-functions-sdk"; 6 | import ky from "ky"; // we recommend using ky as axios doesn't support fetch by default 7 | 8 | const ORACLE_ABI = [ 9 | "function lastUpdated() external view returns(uint256)", 10 | "function updatePrice(uint256)", 11 | ]; 12 | 13 | Web3Function.onRun(async (context: Web3FunctionContext) => { 14 | const { userArgs, multiChainProvider } = context; 15 | 16 | const provider = multiChainProvider.default(); 17 | 18 | // Retrieve Last oracle update time 19 | let lastUpdated; 20 | let oracle; 21 | 22 | const oracleAddress = userArgs.oracle as string; 23 | 24 | try { 25 | oracle = new Contract(oracleAddress, ORACLE_ABI, provider); 26 | lastUpdated = parseInt(await oracle.lastUpdated()); 27 | console.log(`Last oracle update: ${lastUpdated}`); 28 | 29 | // Check if it's ready for a new update 30 | const nextUpdateTime = lastUpdated + 300; // 5 min 31 | const timestamp = (await provider.getBlock("latest")).timestamp; 32 | console.log(`Next oracle update: ${nextUpdateTime}`); 33 | if (timestamp < nextUpdateTime) { 34 | return { canExec: false, message: `Time not elapsed` }; 35 | } 36 | } catch (err) { 37 | console.log(`Error: ${err.message}`); 38 | return { canExec: false, message: `Rpc call failed` }; 39 | } 40 | 41 | // Get current price on coingecko 42 | const currency = userArgs.currency as string; 43 | let price = 0; 44 | try { 45 | const priceData: any = await ky 46 | .get( 47 | `https://api.coingecko.com/api/v3/simple/price?ids=${currency}&vs_currencies=usd`, 48 | { timeout: 5_000, retry: 0 } 49 | ) 50 | .json(); 51 | price = Math.floor(priceData[currency].usd); 52 | } catch (err) { 53 | return { canExec: false, message: `Coingecko call failed` }; 54 | } 55 | console.log(`Updating price: ${price}`); 56 | 57 | // Return execution call data 58 | return { 59 | canExec: true, 60 | callData: [ 61 | { 62 | to: oracleAddress, 63 | data: oracle.interface.encodeFunctionData("updatePrice", [price]), 64 | }, 65 | ], 66 | }; 67 | }); 68 | -------------------------------------------------------------------------------- /src/bin/index.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import * as semver from "semver"; 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const packageJson = require("../../package.json"); 5 | import colors from "colors/safe"; 6 | 7 | const KO = colors.red("✗"); 8 | 9 | const verifyNodeVersionAndRun = async () => { 10 | const supportedNodeVersionRange = packageJson.engines.node; 11 | const currentVersion = process.version; 12 | if (!semver.satisfies(currentVersion, supportedNodeVersionRange)) { 13 | console.error( 14 | `${KO}: You are using Node.js version ${currentVersion}, but w3f CLI requires Node.js version ${supportedNodeVersionRange}. Please upgrade your Node.js version.` 15 | ); 16 | } else await runCliCommand(); 17 | }; 18 | 19 | const runCliCommand = async () => { 20 | const command = process.argv[2]; 21 | 22 | const benchmark = await import("../lib/binaries/benchmark"); 23 | const fetch = await import("../lib/binaries/fetch"); 24 | const deploy = await import("../lib/binaries/deploy"); 25 | const schema = await import("../lib/binaries/schema"); 26 | const test = await import("../lib/binaries/test"); 27 | 28 | switch (command) { 29 | case "test": 30 | test 31 | .default() 32 | .catch((err) => 33 | console.error(` ${KO} Error running Web3Function: ${err.message}`) 34 | ); 35 | break; 36 | case "benchmark": 37 | benchmark 38 | .default() 39 | .catch((err) => 40 | console.error(` ${KO} Error running benchmark: ${err.message}`) 41 | ); 42 | break; 43 | case "fetch": 44 | fetch 45 | .default() 46 | .catch((err) => 47 | console.error(` ${KO} Fetching Web3Function failed: ${err.message}`) 48 | ); 49 | break; 50 | case "deploy": 51 | deploy 52 | .default() 53 | .catch((err) => 54 | console.error(` ${KO} Deploying Web3Function failed: ${err.message}`) 55 | ); 56 | break; 57 | case "schema": 58 | schema 59 | .default() 60 | .catch((err) => 61 | console.error(` ${KO} Fetching schema failed: ${err.message}`) 62 | ); 63 | break; 64 | default: 65 | console.error(` ${KO} Unknown command: ${command}`); 66 | } 67 | }; 68 | 69 | verifyNodeVersionAndRun(); 70 | -------------------------------------------------------------------------------- /src/hardhat/provider/index.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | import { EthereumProvider, HardhatRuntimeEnvironment } from "hardhat/types"; 3 | import { MultiChainProviderConfig } from "../../lib/provider"; 4 | 5 | export async function getMultiChainProviderConfigs( 6 | hre: HardhatRuntimeEnvironment 7 | ) { 8 | const multiChainProviderConfig: MultiChainProviderConfig = {}; 9 | 10 | try { 11 | const networks = hre.config.w3f.networks; 12 | for (const network of networks) { 13 | if (network != "hardhat") { 14 | const networkConfig = hre.userConfig.networks?.[network]; 15 | if (!networkConfig) 16 | throw new Error(`Config for network ${network} not found`); 17 | 18 | const url = networkConfig["url"]; 19 | if (!url) throw new Error(`'url' for network ${network} not found`); 20 | 21 | const provider = new StaticJsonRpcProvider(url); 22 | const chainId = 23 | networkConfig.chainId ?? (await provider.getNetwork()).chainId; 24 | 25 | multiChainProviderConfig[chainId] = provider; 26 | } else { 27 | const provider = new EthersProviderWrapper(hre.network.provider); 28 | const chainId = 31337; //hardhat chain id 29 | 30 | multiChainProviderConfig[chainId] = provider; 31 | } 32 | } 33 | } catch (err) { 34 | console.error( 35 | `Fail to start Web3FunctionMultiChainProvider: ${err.message}` 36 | ); 37 | } 38 | 39 | return multiChainProviderConfig; 40 | } 41 | 42 | export class EthersProviderWrapper extends StaticJsonRpcProvider { 43 | private readonly _hardhatProvider: EthereumProvider; 44 | 45 | constructor(hardhatProvider: EthereumProvider) { 46 | super(); 47 | this._hardhatProvider = hardhatProvider; 48 | } 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | public async send(method: string, params: any): Promise { 52 | const result = await this._hardhatProvider.send(method, params); 53 | 54 | // We replicate ethers' behavior. 55 | this.emit("debug", { 56 | action: "send", 57 | request: { 58 | id: 42, 59 | jsonrpc: "2.0", 60 | method, 61 | params, 62 | }, 63 | response: result, 64 | provider: this, 65 | }); 66 | 67 | return result; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/Web3FunctionThreadSandbox.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty */ 2 | import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; 3 | import path from "path"; 4 | import pidusage from "pidusage"; 5 | import { Web3FunctionVersion } from "../../types"; 6 | import { Web3FunctionAbstractSandbox } from "./Web3FunctionAbstractSandbox"; 7 | 8 | const HTTP_PROXY_HOST = "127.0.0.1"; 9 | 10 | export class Web3FunctionThreadSandbox extends Web3FunctionAbstractSandbox { 11 | private _thread?: ChildProcessWithoutNullStreams; 12 | 13 | protected async _stop(): Promise { 14 | if (!this._thread) return; 15 | this._thread.kill("SIGKILL"); 16 | } 17 | 18 | protected async _start( 19 | script: string, 20 | version: Web3FunctionVersion, 21 | serverPort: number, 22 | mountPath: string, 23 | httpProxyPort: number, 24 | args: string[] 25 | ): Promise { 26 | const cmd = 27 | process.env.DENO_PATH ?? 28 | path.join(process.cwd(), "node_modules", "deno-bin", "bin", "deno"); 29 | 30 | let env = {}; 31 | 32 | if (version === Web3FunctionVersion.V1_0_0) { 33 | env = { WEB3_FUNCTION_SERVER_PORT: serverPort.toString() }; 34 | } else { 35 | env = { 36 | WEB3_FUNCTION_SERVER_PORT: serverPort.toString(), 37 | WEB3_FUNCTION_MOUNT_PATH: mountPath, 38 | }; 39 | } 40 | 41 | const httpProxyUrl = `${HTTP_PROXY_HOST}:${httpProxyPort}`; 42 | env["HTTP_PROXY"] = httpProxyUrl; 43 | env["HTTPS_PROXY"] = httpProxyUrl; 44 | 45 | args.push(script); 46 | this._thread = spawn(cmd, args, { 47 | shell: true, 48 | cwd: process.cwd(), 49 | env, 50 | }); 51 | 52 | let processExitCodeFunction; 53 | this._processExitCodePromise = new Promise((resolve) => { 54 | processExitCodeFunction = resolve; 55 | }); 56 | 57 | this._thread.on("close", (code: number, signal: string) => { 58 | this._log(`Thread exited with code=${code} signal=${signal}`); 59 | processExitCodeFunction(code); 60 | }); 61 | this._thread.stdout.on("data", this._onStdoutData.bind(this)); 62 | this._thread.stderr.on("data", this._onStdoutData.bind(this)); 63 | } 64 | 65 | protected async _getMemoryUsage(): Promise { 66 | const stats = await pidusage(this._thread?.pid); 67 | return stats?.memory; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/runtime/Web3FunctionRunnerPool.spec.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | import { jest } from "@jest/globals"; 3 | import path from "path"; 4 | 5 | import { Web3FunctionBuilder } from "../builder"; 6 | import { 7 | Web3FunctionContextData, 8 | Web3FunctionRunnerOptions, 9 | Web3FunctionVersion, 10 | } from "../types"; 11 | import { Web3FunctionRunnerPool } from "./Web3FunctionRunnerPool"; 12 | 13 | const MAX_RPC_LIMIT = 100; 14 | const MAX_DOWNLOAD_LIMIT = 10 * 1024 * 1024; 15 | const MAX_UPLOAD_LIMIT = 5 * 1024 * 1024; 16 | const MAX_REQUEST_LIMIT = 110; 17 | const MAX_STORAGE_LIMIT = 1024; // kb 18 | 19 | describe("Web3FunctionRunnerPool", () => { 20 | const LOCAL_BASE_PATH = path.join( 21 | process.cwd(), 22 | "src", 23 | "lib", 24 | "runtime", 25 | "__test__" 26 | ); 27 | test("runner pool", async () => { 28 | const consoleSpy = jest.spyOn(console, "log"); 29 | 30 | const buildRes = await Web3FunctionBuilder.build( 31 | path.join(LOCAL_BASE_PATH, "simple.ts") 32 | ); 33 | 34 | if (buildRes.success) { 35 | const multiChainProviderConfig = { 36 | 11155111: new StaticJsonRpcProvider("https://rpc.ankr.com/eth_sepolia"), 37 | }; 38 | 39 | const runner = new Web3FunctionRunnerPool(2, true); 40 | const options: Web3FunctionRunnerOptions = { 41 | runtime: "thread", 42 | showLogs: false, 43 | memory: buildRes.schema.memory, 44 | rpcLimit: MAX_RPC_LIMIT, 45 | timeout: buildRes.schema.timeout * 1000, 46 | downloadLimit: MAX_DOWNLOAD_LIMIT, 47 | uploadLimit: MAX_UPLOAD_LIMIT, 48 | requestLimit: MAX_REQUEST_LIMIT, 49 | storageLimit: MAX_STORAGE_LIMIT, 50 | blacklistedHosts: ["testblacklistedhost.com"], 51 | }; 52 | 53 | const context: Web3FunctionContextData<"onRun"> = { 54 | secrets: {}, 55 | storage: {}, 56 | gelatoArgs: { 57 | chainId: 11155111, 58 | gasPrice: "10", 59 | }, 60 | userArgs: {}, 61 | }; 62 | 63 | await runner.run("onRun", { 64 | script: buildRes.filePath, 65 | version: Web3FunctionVersion.V2_0_0, 66 | context, 67 | options, 68 | multiChainProviderConfig, 69 | }); 70 | 71 | expect(consoleSpy).toHaveBeenCalledWith( 72 | expect.stringContaining("Web3FunctionRunnerPool") 73 | ); 74 | } else { 75 | expect(true).toBeFalsy(); 76 | } 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/lib/runtime/types/Web3FunctionExecResult.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3FunctionCallbackStatus, 3 | Web3FunctionOperation, 4 | Web3FunctionStorageWithSize, 5 | Web3FunctionVersion, 6 | } from "../../types"; 7 | import { 8 | Web3FunctionResultV1, 9 | Web3FunctionResultV2, 10 | } from "../../types/Web3FunctionResult"; 11 | 12 | export type Web3FunctionThrottled = { 13 | memory?: boolean; 14 | storage?: boolean; 15 | duration?: boolean; 16 | rpcRequest?: boolean; 17 | networkRequest?: boolean; 18 | download?: boolean; 19 | upload?: boolean; 20 | }; 21 | 22 | type Web3FunctionExecStats = { 23 | version: Web3FunctionVersion; 24 | duration: number; 25 | memory: number; 26 | rpcCalls: { total: number; throttled: number }; 27 | logs: string[]; 28 | network: { 29 | nbRequests: number; 30 | nbThrottled: number; 31 | download: number; // in KB 32 | upload: number; // in KB 33 | }; 34 | throttled: Web3FunctionThrottled; 35 | }; 36 | 37 | export type Web3FunctionExecSuccessBase = Web3FunctionExecStats & { 38 | success: true; 39 | storage: Web3FunctionStorageWithSize; 40 | callbacks: Web3FunctionCallbackStatus; 41 | }; 42 | 43 | export type Web3FunctionExecSuccessV1 = Web3FunctionExecSuccessBase & { 44 | version: Web3FunctionVersion.V1_0_0; 45 | result: Web3FunctionResultV1; 46 | }; 47 | 48 | export type Web3FunctionExecSuccessV2 = Web3FunctionExecSuccessBase & { 49 | version: Web3FunctionVersion.V2_0_0; 50 | result: Web3FunctionResultV2; 51 | }; 52 | 53 | export type Web3FunctionExecSuccessCallback = Web3FunctionExecSuccessBase & { 54 | result: undefined; 55 | }; 56 | 57 | export type Web3FunctionExecSuccess = 58 | T extends "onRun" 59 | ? Web3FunctionExecSuccessV1 | Web3FunctionExecSuccessV2 60 | : Web3FunctionExecSuccessCallback; 61 | 62 | export class Web3FunctionRuntimeError extends Error { 63 | throttledReason?: "memory" | "duration" | "rpcRequest"; 64 | 65 | constructor( 66 | message: string, 67 | throttledReason?: "memory" | "duration" | "rpcRequest" 68 | ) { 69 | super(message); 70 | this.throttledReason = throttledReason; 71 | } 72 | } 73 | 74 | type Web3FunctionExecFail = Web3FunctionExecStats & { 75 | success: false; 76 | error: Web3FunctionRuntimeError; 77 | callbacks?: Web3FunctionCallbackStatus; 78 | }; 79 | 80 | export type Web3FunctionExec = 81 | | Web3FunctionExecSuccess 82 | | Web3FunctionExecFail; 83 | 84 | export type Web3FunctionExecAny = Web3FunctionExec; 85 | -------------------------------------------------------------------------------- /src/web3-functions/secrets/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Web3Function, 3 | Web3FunctionContext, 4 | } from "@gelatonetwork/web3-functions-sdk"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | import ky from "ky"; // we recommend using ky as axios doesn't support fetch by default 7 | 8 | const ORACLE_ABI = [ 9 | "function lastUpdated() external view returns(uint256)", 10 | "function updatePrice(uint256)", 11 | ]; 12 | 13 | Web3Function.onRun(async (context: Web3FunctionContext) => { 14 | const { userArgs, multiChainProvider } = context; 15 | 16 | const provider = multiChainProvider.default(); 17 | 18 | // Retrieve Last oracle update time 19 | const oracleAddress = 20 | (userArgs.oracle as string) ?? "0x71B9B0F6C999CBbB0FeF9c92B80D54e4973214da"; 21 | let lastUpdated; 22 | let oracle; 23 | try { 24 | oracle = new Contract(oracleAddress, ORACLE_ABI, provider); 25 | lastUpdated = parseInt(await oracle.lastUpdated()); 26 | console.log(`Last oracle update: ${lastUpdated}`); 27 | } catch (err) { 28 | return { canExec: false, message: `Rpc call failed` }; 29 | } 30 | 31 | // Check if it's ready for a new update 32 | const nextUpdateTime = lastUpdated + 300; // 5 min 33 | const timestamp = (await provider.getBlock("latest")).timestamp; 34 | console.log(`Next oracle update: ${nextUpdateTime}`); 35 | if (timestamp < nextUpdateTime) { 36 | return { canExec: false, message: `Time not elapsed` }; 37 | } 38 | 39 | // Get current price on coingecko 40 | const currency = (userArgs.currency as string) ?? "ethereum"; 41 | let price = 0; 42 | try { 43 | // Get api from secrets 44 | const coingeckoApi = await context.secrets.get("COINGECKO_API"); 45 | if (!coingeckoApi) 46 | return { canExec: false, message: `COINGECKO_API not set in secrets` }; 47 | 48 | const coingeckoSimplePriceApi = `${coingeckoApi}/simple/price?ids=${currency}&vs_currencies=usd`; 49 | console.log(coingeckoSimplePriceApi); 50 | 51 | const priceData: { [key: string]: { usd: number } } = await ky 52 | .get(coingeckoSimplePriceApi, { timeout: 5_000, retry: 0 }) 53 | .json(); 54 | price = Math.floor(priceData[currency].usd); 55 | } catch (err) { 56 | return { canExec: false, message: `Coingecko call failed` }; 57 | } 58 | console.log(`Updating price: ${price}`); 59 | 60 | // Return execution call data 61 | return { 62 | canExec: true, 63 | callData: [ 64 | { 65 | to: oracleAddress, 66 | data: oracle.interface.encodeFunctionData("updatePrice", [price]), 67 | }, 68 | ], 69 | }; 70 | }); 71 | -------------------------------------------------------------------------------- /src/lib/net/Web3FunctionHttpServer.ts: -------------------------------------------------------------------------------- 1 | import { Web3FunctionEvent } from "../types/Web3FunctionEvent"; 2 | 3 | export class Web3FunctionHttpServer { 4 | private _eventHandler: ( 5 | event: Web3FunctionEvent 6 | ) => Promise; 7 | private _waitConnectionReleased: Promise = Promise.resolve(); 8 | private _debug: boolean; 9 | 10 | constructor( 11 | port: number, 12 | mountPath: string, 13 | debug: boolean, 14 | eventHandler: (event: Web3FunctionEvent) => Promise 15 | ) { 16 | this._debug = debug; 17 | this._eventHandler = eventHandler; 18 | this._setupConnection(port, mountPath); 19 | } 20 | 21 | private async _setupConnection(port: number, mountPath: string) { 22 | const conns = Deno.listen({ port, hostname: "0.0.0.0" }); 23 | this._log(`Listening on http://${conns.addr.hostname}:${conns.addr.port}`); 24 | 25 | for await (const conn of conns) { 26 | try { 27 | // eslint-disable-next-line @typescript-eslint/no-empty-function 28 | let connectionReleaseResolver = () => { 29 | // Intentionally left empty to use as variable 30 | }; 31 | this._waitConnectionReleased = new Promise((resolve) => { 32 | connectionReleaseResolver = () => { 33 | resolve(); 34 | }; 35 | }); 36 | 37 | for await (const e of Deno.serveHttp(conn)) { 38 | try { 39 | const res = await this._onRequest(e.request, mountPath); 40 | await e.respondWith(res); 41 | } catch (err) { 42 | this._log(`Request Error: ${err.message}`); 43 | await e.respondWith( 44 | new Response(`Internal error: ${err.message}`, { status: 500 }) 45 | ); 46 | } 47 | } 48 | connectionReleaseResolver(); 49 | } catch (err) { 50 | this._log(`Connection Error: ${err.message}`); 51 | } 52 | } 53 | } 54 | 55 | private async _onRequest(req: Request, mountPath: string) { 56 | if (!this._isValidMountPath(req, mountPath)) { 57 | return new Response("invalid path", { status: 400 }); 58 | } 59 | 60 | switch (req.method) { 61 | case "GET": 62 | return new Response("ok"); 63 | case "POST": { 64 | const event = (await req.json()) as Web3FunctionEvent; 65 | const res = await this._eventHandler(event); 66 | return new Response(JSON.stringify(res)); 67 | } 68 | default: 69 | return new Response(`unsupported method: ${req.method}`, { 70 | status: 500, 71 | }); 72 | } 73 | } 74 | 75 | private _isValidMountPath(req: Request, mountPath: string) { 76 | const { pathname } = new URL(req.url); 77 | 78 | if (pathname === `/${mountPath}`) return true; 79 | return false; 80 | } 81 | 82 | private _log(message: string) { 83 | if (this._debug) console.log(`Web3FunctionHttpServer: ${message}`); 84 | } 85 | 86 | public async waitConnectionReleased() { 87 | await this._waitConnectionReleased; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/lib/runtime/Web3FunctionRunnerPool.ts: -------------------------------------------------------------------------------- 1 | import { Web3FunctionNetHelper } from "../net/Web3FunctionNetHelper"; 2 | import { Web3FunctionOperation } from "../types"; 3 | import { Web3FunctionExec, Web3FunctionRunnerPayload } from "./types"; 4 | import { Web3FunctionRunner } from "./Web3FunctionRunner"; 5 | 6 | export class Web3FunctionRunnerPool { 7 | private _poolSize: number; 8 | private _queuedRunners: (() => Promise)[] = []; 9 | private _activeRunners = 0; 10 | private _debug: boolean; 11 | private _tcpPortsAvailable: number[] = []; 12 | 13 | constructor(poolSize = 10, debug = true) { 14 | this._poolSize = poolSize; 15 | this._debug = debug; 16 | } 17 | 18 | public async init() { 19 | this._tcpPortsAvailable = await Web3FunctionNetHelper.getAvailablePorts( 20 | (this._poolSize + 5) * 3 // 3 ports per concurrent runner + 5 extra 21 | ); 22 | } 23 | 24 | public async run( 25 | operation: T, 26 | payload: Web3FunctionRunnerPayload 27 | ): Promise> { 28 | return this._enqueueAndWait(operation, payload); 29 | } 30 | 31 | private async _enqueueAndWait( 32 | operation: T, 33 | payload: Web3FunctionRunnerPayload 34 | ): Promise> { 35 | return new Promise((resolve, reject) => { 36 | this._queuedRunners.push(async (): Promise => { 37 | this._activeRunners = this._activeRunners + 1; 38 | const port1 = this._tcpPortsAvailable.shift(); 39 | const port2 = this._tcpPortsAvailable.shift(); 40 | const port3 = this._tcpPortsAvailable.shift(); 41 | try { 42 | this._log( 43 | `Starting Web3FunctionRunner, active=${this._activeRunners} ports=${port1},${port2},${port3}` 44 | ); 45 | const runner = new Web3FunctionRunner( 46 | this._debug, 47 | this._tcpPortsAvailable 48 | ); 49 | payload.options.serverPort = port1; 50 | payload.options.httpProxyPort = port2; 51 | payload.options.rpcProxyPort = port3; 52 | const exec = await runner.run(operation, payload); 53 | resolve(exec); 54 | } catch (err) { 55 | reject(err); 56 | } finally { 57 | if (port1) this._tcpPortsAvailable.push(port1); 58 | if (port2) this._tcpPortsAvailable.push(port2); 59 | if (port3) this._tcpPortsAvailable.push(port3); 60 | this._activeRunners = this._activeRunners - 1; 61 | } 62 | }); 63 | 64 | if (this._activeRunners < this._poolSize) { 65 | this._processNext(); 66 | } 67 | }); 68 | } 69 | 70 | private async _processNext() { 71 | for ( 72 | let runner = this._queuedRunners.shift(); 73 | runner; 74 | runner = this._queuedRunners.shift() 75 | ) { 76 | this._log(`_processNext, active=${this._activeRunners}`); 77 | await runner(); 78 | } 79 | } 80 | 81 | private _log(message: string) { 82 | if (this._debug) console.log(`Web3FunctionRunnerPool: ${message}`); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/lib/net/Web3FunctionHttpClient.spec.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { Web3FunctionEvent } from "../types"; 3 | import { Web3FunctionHttpClient } from "./Web3FunctionHttpClient"; 4 | 5 | const TEST_PORT = 3500; 6 | 7 | describe("Web3FunctionHttpClient", () => { 8 | let invalidClient: Web3FunctionHttpClient; 9 | let client: Web3FunctionHttpClient; 10 | let simpleServer: express.Application; 11 | let expressServer; 12 | 13 | const startListening = () => { 14 | expressServer = simpleServer.listen(TEST_PORT); 15 | }; 16 | 17 | beforeAll(() => { 18 | simpleServer = express(); 19 | simpleServer.use(express.json()); 20 | 21 | simpleServer.get("/valid", (req, res) => { 22 | res.sendStatus(200); 23 | }); 24 | 25 | simpleServer.post("/valid", (req, res) => { 26 | const data = req.body; 27 | 28 | if (data.data.malformed) { 29 | res.json({ malformed: true }); 30 | } else { 31 | res.send( 32 | JSON.stringify({ 33 | action: "error", 34 | data: { 35 | error: { 36 | name: "Just testing", 37 | message: `Just testing`, 38 | }, 39 | storage: { 40 | state: "last", 41 | storage: {}, 42 | diff: {}, 43 | }, 44 | }, 45 | }) 46 | ); 47 | } 48 | }); 49 | 50 | startListening(); 51 | 52 | invalidClient = new Web3FunctionHttpClient( 53 | "http://localhost", 54 | TEST_PORT, 55 | "invalid" 56 | ); 57 | client = new Web3FunctionHttpClient( 58 | "http://localhost", 59 | TEST_PORT, 60 | "valid", 61 | false 62 | ); 63 | }); 64 | 65 | test("should timeout connection if not accessible", async () => { 66 | await expect(() => invalidClient.connect(100)).rejects.toThrowError( 67 | "Web3FunctionHttpClient unable to connect" 68 | ); 69 | }); 70 | 71 | test("should disconnect while stopped during connection", async () => { 72 | await expect(() => 73 | Promise.all([client.connect(100), client.end()]) 74 | ).rejects.toThrowError("Disconnected"); 75 | }); 76 | 77 | test("should error out when connection is lost during send", (done) => { 78 | client.emit("input_event", { action: "start", data: {} }); 79 | expressServer.close(); 80 | 81 | const errorHandler = (error) => { 82 | expect(error.message).toMatch("Web3FunctionHttpClient request error"); 83 | 84 | startListening(); 85 | 86 | client.off("error", errorHandler); 87 | done(); 88 | }; 89 | 90 | client.on("error", errorHandler); 91 | }); 92 | 93 | test("should send and receive web3function events", (done) => { 94 | client.emit("input_event", { action: "start", data: {} }); 95 | 96 | client.on("output_event", (event: Web3FunctionEvent) => { 97 | expect(event.action).toBe("error"); 98 | 99 | if (event.action === "error") { 100 | expect(event.data.error.message).toMatch("Just testing"); 101 | } 102 | 103 | done(); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/lib/types/Web3FunctionContext.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "@ethersproject/bignumber"; 2 | import { Log } from "@ethersproject/providers"; 3 | import { Web3FunctionMultiChainProvider } from "../provider/Web3FunctionMultiChainProvider"; 4 | import { Web3FunctionOperation } from "./Web3FunctionOperation"; 5 | import { Web3FunctionResultCallData } from "./Web3FunctionResult"; 6 | import { Web3FunctionUserArgs } from "./Web3FunctionUserArgs"; 7 | 8 | export type Web3FunctionContextData = 9 | T extends "onRun" 10 | ? Web3FunctionOnRunContextData 11 | : T extends "onFail" 12 | ? Web3FunctionOnFailContextData 13 | : T extends "onSuccess" 14 | ? Web3FunctionOnSuccessContextData 15 | : never; 16 | 17 | export type Web3FunctionOnRunContextData = Web3FunctionContextDataBase; 18 | export interface Web3FunctionOnFailContextData 19 | extends Web3FunctionContextDataBase { 20 | onFailReason: FailReason; 21 | callData?: Web3FunctionResultCallData[]; 22 | transactionHash?: string; 23 | } 24 | export interface Web3FunctionOnSuccessContextData 25 | extends Web3FunctionContextDataBase { 26 | transactionHash?: string; 27 | } 28 | export interface Web3FunctionContextDataBase { 29 | gelatoArgs: { 30 | chainId: number; 31 | gasPrice: string; 32 | taskId?: string; 33 | }; 34 | rpcProviderUrl?: string; 35 | userArgs: Web3FunctionUserArgs; 36 | secrets: { [key: string]: string | undefined }; 37 | storage: { [key: string]: string | undefined }; 38 | log?: Log; 39 | } 40 | 41 | export interface Web3FunctionContext { 42 | gelatoArgs: { 43 | chainId: number; 44 | gasPrice: BigNumber; 45 | taskId?: string; 46 | }; 47 | multiChainProvider: Web3FunctionMultiChainProvider; 48 | userArgs: Web3FunctionUserArgs; 49 | secrets: { 50 | get(key: string): Promise; 51 | }; 52 | storage: { 53 | get(key: string): Promise; 54 | set(key: string, value: string): Promise; 55 | delete(key: string): Promise; 56 | getKeys(): Promise; 57 | getSize(): Promise; 58 | }; 59 | } 60 | 61 | export interface Web3FunctionEventContext extends Web3FunctionContext { 62 | log: Log; 63 | } 64 | export interface Web3FunctionSuccessContext extends Web3FunctionContext { 65 | transactionHash?: string; 66 | } 67 | 68 | export type FailReason = 69 | | "InsufficientFunds" 70 | | "SimulationFailed" 71 | | "ExecutionReverted"; 72 | 73 | export interface Web3FunctionFailContextBase extends Web3FunctionContext { 74 | reason: FailReason; 75 | } 76 | 77 | export interface Web3FunctionSimulationFailContext 78 | extends Web3FunctionFailContextBase { 79 | reason: "SimulationFailed"; 80 | callData: Web3FunctionResultCallData[]; 81 | } 82 | export interface Web3FunctionExecutionRevertedContext 83 | extends Web3FunctionFailContextBase { 84 | reason: "ExecutionReverted"; 85 | transactionHash: string; 86 | } 87 | 88 | export interface Web3FunctionInsufficientFundsContext 89 | extends Web3FunctionFailContextBase { 90 | reason: "InsufficientFunds"; 91 | } 92 | 93 | export type Web3FunctionFailContext = 94 | | Web3FunctionSimulationFailContext 95 | | Web3FunctionExecutionRevertedContext 96 | | Web3FunctionInsufficientFundsContext; 97 | -------------------------------------------------------------------------------- /src/lib/provider/Web3FunctionMultiChainProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | import { Web3FunctionMultiChainProvider } from "./Web3FunctionMultiChainProvider"; 3 | import { Web3FunctionProxyProvider } from "./Web3FunctionProxyProvider"; 4 | 5 | describe("Web3FunctionMultiChainProvider", () => { 6 | enum TestChainIds { 7 | Sepolia = 11155111, 8 | Amoy = 80002, 9 | } 10 | enum TestChainProviders { 11 | Sepolia = "https://rpc.ankr.com/eth_sepolia", 12 | Amoy = "https://rpc.ankr.com/polygon_amoy", 13 | } 14 | 15 | let proxyProvider: Web3FunctionProxyProvider; 16 | let multichainProvider: Web3FunctionMultiChainProvider; 17 | 18 | let rateLimitInvoked = false; 19 | 20 | const rpcLimit = 5; 21 | beforeAll(async () => { 22 | const proxyProviderHost = "http://127.0.0.1"; 23 | const proxyProviderPort = 3000; 24 | 25 | const multiChainProviderConfig = { 26 | [TestChainIds.Sepolia]: new StaticJsonRpcProvider( 27 | TestChainProviders.Sepolia 28 | ), 29 | [TestChainIds.Amoy]: new StaticJsonRpcProvider(TestChainProviders.Amoy), 30 | }; 31 | 32 | proxyProvider = new Web3FunctionProxyProvider( 33 | proxyProviderHost, 34 | rpcLimit, 35 | TestChainIds.Sepolia, 36 | multiChainProviderConfig, 37 | false 38 | ); 39 | 40 | await proxyProvider.start(proxyProviderPort); 41 | 42 | multichainProvider = new Web3FunctionMultiChainProvider( 43 | proxyProvider.getProxyUrl(), 44 | TestChainIds.Sepolia, 45 | () => { 46 | rateLimitInvoked = true; 47 | } 48 | ); 49 | }); 50 | 51 | afterAll(() => { 52 | proxyProvider.stop(); 53 | }); 54 | 55 | test("should get remaining rpc calls", async () => { 56 | let nbRpcCallsRemaining = await multichainProvider.nbRpcCallsRemaining(); 57 | expect(nbRpcCallsRemaining).toBe(rpcLimit); 58 | 59 | await multichainProvider.default().getBlock("latest"); 60 | 61 | nbRpcCallsRemaining = await multichainProvider.nbRpcCallsRemaining(); 62 | expect(nbRpcCallsRemaining).toBe(rpcLimit - 1); 63 | }); 64 | 65 | test("should get default provider with chainId", async () => { 66 | const chainNetwork = await multichainProvider 67 | .chainId(11155111) 68 | .getNetwork(); 69 | const mainChainNetwork = await multichainProvider.default().getNetwork(); 70 | 71 | expect(chainNetwork.chainId).toEqual(mainChainNetwork.chainId); 72 | }); 73 | 74 | test("should invoke rate limit callback when rate limit exceed", async () => { 75 | rateLimitInvoked = false; 76 | 77 | const limitingRequests = Array.from( 78 | { length: rpcLimit }, 79 | async () => await multichainProvider.default().getBlock("latest") 80 | ); 81 | 82 | try { 83 | await Promise.allSettled(limitingRequests); 84 | } catch (error) { 85 | expect(rateLimitInvoked).toBeTruthy(); 86 | } 87 | }, 20_000); 88 | 89 | test("should fail when RPC is not configured for the chainId", async () => { 90 | try { 91 | await multichainProvider.chainId(100).getBlockNumber(); 92 | throw new Error("Provider is connected"); 93 | } catch (error) { 94 | expect(error.message.includes("provider is disconnected")); 95 | } 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/lib/loader/Web3FunctionLoader.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | import { W3fDetails } from "./types"; 6 | 7 | export class Web3FunctionLoader { 8 | private static _cache = new Map(); 9 | 10 | private static _loadJson(path: string) { 11 | if (fs.existsSync(path)) { 12 | const jsonString = fs.readFileSync(path, "utf8"); 13 | return JSON.parse(jsonString); 14 | } 15 | 16 | return {}; 17 | } 18 | 19 | private static _loadLog(path: string) { 20 | const log = this._loadJson(path); 21 | 22 | return Object.keys(log).length === 0 ? undefined : log; 23 | } 24 | 25 | private static _loadSecrets(path: string) { 26 | const secrets = {}; 27 | 28 | if (fs.existsSync(path)) { 29 | const config = dotenv.config({ path }).parsed ?? {}; 30 | Object.keys(config).forEach((key) => { 31 | secrets[key] = config[key]; 32 | }); 33 | } 34 | 35 | return secrets; 36 | } 37 | 38 | public static load(w3fName: string, w3fRootDir: string): W3fDetails { 39 | const w3fPath = path.join(w3fRootDir, w3fName); 40 | const cachedDetails = this._cache.get(w3fPath); 41 | if (cachedDetails) { 42 | return cachedDetails; 43 | } 44 | 45 | const details: W3fDetails = { 46 | path: "", 47 | userArgs: {}, 48 | storage: {}, 49 | secrets: {}, 50 | }; 51 | 52 | const stats = fs.statSync(w3fPath); 53 | if (stats.isDirectory()) { 54 | const jsPath = path.join(w3fPath, "index.js"); 55 | const tsPath = path.join(w3fPath, "index.ts"); 56 | const userArgsJsonPath = path.join(w3fPath, "userArgs.json"); 57 | const storageJsonPath = path.join(w3fPath, "storage.json"); 58 | const logJsonPath = path.join(w3fPath, "log.json"); 59 | const secretsPath = path.join(w3fPath, ".env"); 60 | 61 | // Get web3 function 62 | if (fs.existsSync(tsPath)) { 63 | details.path = tsPath; 64 | } else if (fs.existsSync(jsPath)) { 65 | details.path = jsPath; 66 | } else throw new Error(`Web3 Function "${w3fName}" not found!`); 67 | 68 | // Get userArgs 69 | try { 70 | details.userArgs = this._loadJson(userArgsJsonPath); 71 | } catch (error) { 72 | console.error( 73 | `Error reading userArgs.json for ${w3fName}: ${error.message}` 74 | ); 75 | } 76 | 77 | // Get storage 78 | try { 79 | details.storage = this._loadJson(storageJsonPath); 80 | } catch (error) { 81 | console.error( 82 | `Error reading storage.json for ${w3fName}: ${error.message}` 83 | ); 84 | } 85 | 86 | // Get secrets 87 | try { 88 | details.secrets = this._loadSecrets(secretsPath); 89 | } catch (error) { 90 | console.error(`Error reading .env for ${w3fName}: ${error.message}`); 91 | } 92 | 93 | // Get event log 94 | try { 95 | details.log = this._loadLog(logJsonPath); 96 | } catch (error) { 97 | console.error( 98 | `Error reading log.json for ${w3fName}: ${error.message}` 99 | ); 100 | } 101 | } 102 | 103 | this._cache.set(w3fPath, details); 104 | return details; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/web3-functions/fails/rpc-provider-limit/index.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from "@ethersproject/contracts"; 2 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 3 | import { 4 | Web3Function, 5 | Web3FunctionContext, 6 | } from "@gelatonetwork/web3-functions-sdk"; 7 | import ky from "ky"; 8 | 9 | const assert = { 10 | match: (a: string, b: RegExp) => { 11 | if (!b.test(a)) { 12 | console.error(`AssertFail: ${a} is not matching ${b}`); 13 | Deno.exit(1); 14 | } 15 | }, 16 | instanceOf: (a: any, b: ObjectConstructor) => { 17 | if (!(a instanceof b)) { 18 | console.error(`AssertFail: ${a} is not an instance of ${b}`); 19 | Deno.exit(1); 20 | } 21 | }, 22 | }; 23 | 24 | const ORACLE_ABI = [ 25 | "function lastUpdated() external view returns(uint256)", 26 | "function updatePrice(uint256)", 27 | ]; 28 | 29 | Web3Function.onRun(async (context: Web3FunctionContext) => { 30 | const { multiChainProvider } = context; 31 | 32 | const provider = multiChainProvider.default(); 33 | // Test sending invalid request 34 | let failure = ""; 35 | try { 36 | await provider.send("eth_test", []); 37 | } catch (err) { 38 | failure = err.message; 39 | console.log("Invalid Rpc method error:", failure); 40 | } 41 | assert.match(failure, /"code":-(32601|32600)/); 42 | assert.match( 43 | failure, 44 | /the method eth_test does not exist|Method not found|Unsupported method/ 45 | ); 46 | 47 | // Test sending invalid params 48 | try { 49 | await provider.send("eth_call", ["", "", ""]); 50 | } catch (err) { 51 | failure = err.message; 52 | console.log("Invalid Rpc params error:", err.message); 53 | } 54 | assert.match(failure, /"code":-32602/); 55 | assert.match( 56 | failure, 57 | /invalid argument 0|invalid 1st argument|Invalid params/ 58 | ); 59 | 60 | // Test sending http query 61 | try { 62 | const res = await ky 63 | .post((provider as StaticJsonRpcProvider).connection.url) 64 | .text(); 65 | console.log(res); 66 | failure = res; 67 | } catch (err) { 68 | console.log("Invalid Rpc request error:", err.message); 69 | } 70 | assert.match(failure, /"code":-32600/); 71 | assert.match(failure, /The JSON sent is not a valid Request object/); 72 | 73 | // Test soft rate limits 74 | const oracleAddress = "0x6a3c82330164822A8a39C7C0224D20DB35DD030a"; 75 | const oracle = new Contract(oracleAddress, ORACLE_ABI, provider); 76 | let value; 77 | try { 78 | const promises: Promise[] = []; 79 | for (let i = 0; i < 5; i++) promises.push(oracle.lastUpdated()); 80 | value = await Promise.race(promises); 81 | } catch (err) { 82 | console.log("Throttling RPC calls error:", err.message); 83 | value = err.message; 84 | } 85 | assert.match(value.toString(), /\d+/); 86 | 87 | // Test hard rate limits 88 | for (let j = 0; j < 20; j++) { 89 | try { 90 | await Promise.all(Array.from({ length: 10 }, () => oracle.lastUpdated())); 91 | } catch (err) { 92 | failure = err.message; 93 | console.log("Throttling RPC calls error:", err.message); 94 | } 95 | } 96 | assert.match(failure, /"code":-32005/); 97 | assert.match(failure, /Request limit exceeded/); 98 | 99 | return { canExec: false, message: "RPC providers tests ok!" }; 100 | }); 101 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/Web3FunctionAbstractSandbox.ts: -------------------------------------------------------------------------------- 1 | import colors from "colors/safe"; 2 | import { EventEmitter } from "events"; 3 | import { Web3FunctionVersion } from "../../types"; 4 | import { Web3FunctionSandboxOptions } from "../types"; 5 | 6 | export abstract class Web3FunctionAbstractSandbox extends EventEmitter { 7 | protected _memoryLimit: number; 8 | protected _isStopped = false; 9 | protected _processExitCodePromise = Promise.resolve(0); 10 | protected _showStdout: boolean; 11 | protected _debug: boolean; 12 | protected _logs: string[] = []; 13 | 14 | constructor( 15 | options: Web3FunctionSandboxOptions, 16 | showStdout = true, 17 | debug = true 18 | ) { 19 | super(); 20 | this._memoryLimit = options.memoryLimit; 21 | this._showStdout = showStdout; 22 | this._debug = debug; 23 | } 24 | 25 | public async stop() { 26 | if (!this._isStopped) { 27 | this._isStopped = true; 28 | await this._stop(); 29 | } 30 | } 31 | 32 | protected abstract _stop(): Promise; 33 | protected abstract _start( 34 | script: string, 35 | version: Web3FunctionVersion, 36 | serverPort: number, 37 | mountPath: string, 38 | httpProxyPort: number, 39 | args: string[] 40 | ): Promise; 41 | protected abstract _getMemoryUsage(): Promise; 42 | 43 | public async start( 44 | script: string, 45 | version: Web3FunctionVersion, 46 | serverPort: number, 47 | mountPath: string, 48 | httpProxyPort: number, 49 | blacklistedHosts?: string[] 50 | ) { 51 | const args: string[] = []; 52 | 53 | this._log("Starting sandbox"); 54 | 55 | // Prepare common base args here 56 | args.push("run"); 57 | 58 | if (version === Web3FunctionVersion.V1_0_0) { 59 | args.push(`--allow-env=WEB3_FUNCTION_SERVER_PORT`); 60 | args.push(`--unstable`); 61 | } else { 62 | args.push( 63 | `--allow-env=WEB3_FUNCTION_SERVER_PORT,WEB3_FUNCTION_MOUNT_PATH` 64 | ); 65 | } 66 | 67 | args.push(`--allow-net`); 68 | if (blacklistedHosts && blacklistedHosts.length > 0) { 69 | args.push(`--deny-net=${blacklistedHosts?.toString()}`); 70 | } 71 | 72 | args.push(`--no-prompt`); 73 | args.push(`--no-npm`); 74 | args.push(`--no-remote`); 75 | args.push(`--v8-flags=--max-old-space-size=${this._memoryLimit}`); 76 | 77 | await this._start( 78 | script, 79 | version, 80 | serverPort, 81 | mountPath, 82 | httpProxyPort, 83 | args 84 | ); 85 | } 86 | 87 | public async getMemoryUsage(): Promise { 88 | try { 89 | return (await this._getMemoryUsage()) ?? 0; 90 | } catch (err) { 91 | return 0; 92 | } 93 | } 94 | 95 | public getLogs(): string[] { 96 | return this._logs; 97 | } 98 | 99 | protected _onStdoutData(data: string) { 100 | const output = data.toString(); 101 | output 102 | .split("\n") 103 | .filter((line) => line !== "") 104 | .forEach((line) => { 105 | this._logs.push(line); 106 | if (this._showStdout) 107 | console.log(colors.cyan(`>`), colors.grey(`${line}`)); 108 | }); 109 | } 110 | 111 | public async waitForProcessEnd() { 112 | return await this._processExitCodePromise; 113 | } 114 | 115 | protected _log(message: string) { 116 | if (this._debug) console.log(`Web3FunctionSandbox: ${message}`); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/lib/net/Web3FunctionHttpProxy.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import http from "http"; 3 | import { Web3FunctionHttpProxy } from "./Web3FunctionHttpProxy"; 4 | 5 | const MAX_DOWNLOAD_SIZE = 1 * 1024; 6 | const MAX_UPLOAD_SIZE = 1 * 1024; 7 | const MAX_REQUESTS = 2; 8 | 9 | const limitPayload = `07687025835896630850868449444515\n 10 | 19004885209543960311432986080541\n 11 | 03158281758729558593090926184825\n 12 | 40772319890392416463798330135022\n 13 | 07702013838043904240082977642434\n 14 | 78566525722876389716880511958995\n 15 | 12035921191774613938722741086776\n 16 | 50288953712326350078906469208225\n 17 | 58479170878046405538264425626156\n 18 | 20466941989979842517387202418765\n 19 | 30898445718944664974752192926478\n 20 | 10829190843006647792462329496676\n 21 | 69172422122281182915998920830447\n 22 | 68159615670566716041444574158276\n 23 | 60694522988055341504065499436440\n 24 | 78154146011219258273400776799336\n 25 | 54545430660844152600367106532410\n 26 | 03483614367103665993033133233507\n 27 | 97294966024323840999755783241680\n 28 | 82279112196345575379498270220001\n 29 | 82279112196345575379498270220001\n 30 | 82279112196345575379498270220001\n 31 | `; 32 | 33 | describe("Web3FunctionHttpProxy", () => { 34 | let httpProxy: Web3FunctionHttpProxy; 35 | let testServer: http.Server; 36 | 37 | beforeAll(() => { 38 | axios.defaults.proxy = { 39 | host: "localhost", 40 | port: 3000, 41 | protocol: "http:", 42 | }; 43 | 44 | // Create an HTTP server 45 | testServer = http.createServer((req, res) => { 46 | // Set the response header with a 200 OK status and plain text content type 47 | res.writeHead(200, { "Content-Type": "text/plain" }); 48 | 49 | if (req.url?.includes("limit")) { 50 | res.end(limitPayload); 51 | } else if (req.url?.includes("query")) { 52 | res.end("Query parameters received"); 53 | } else { 54 | // Write the response body 55 | res.end("Hello, World!\n"); 56 | } 57 | }); 58 | 59 | // Listen on port 3000 (you can choose any available port) 60 | const port = 8001; 61 | testServer.listen(port); 62 | }); 63 | 64 | afterAll(() => { 65 | testServer.close(); 66 | }); 67 | 68 | beforeEach(async () => { 69 | httpProxy = new Web3FunctionHttpProxy( 70 | MAX_DOWNLOAD_SIZE, 71 | MAX_UPLOAD_SIZE, 72 | MAX_REQUESTS, 73 | false 74 | ); 75 | 76 | await httpProxy.start(); 77 | }); 78 | 79 | afterEach(async () => { 80 | httpProxy.stop(); 81 | }); 82 | 83 | test("should respond HTTP:429 when request limit exceeded", async () => { 84 | try { 85 | await axios.get("http://localhost:8001"); 86 | await axios.get("http://localhost:8001"); 87 | await axios.get("http://localhost:8001"); 88 | } catch (error) { 89 | if (error.response) { 90 | expect(error.response.status).toEqual(429); 91 | return; 92 | } 93 | } 94 | 95 | throw new Error(`HTTP: Request limit exceeded`); 96 | }); 97 | 98 | test("should forward requests", async () => { 99 | const res = await axios.get("http://localhost:8001/hello"); 100 | expect(res.data).toEqual("Hello, World!\n"); 101 | }); 102 | 103 | test("should forward query parameters", async () => { 104 | const res = await axios.get("http://localhost:8001/test?query=true"); 105 | expect(res.data).toEqual("Query parameters received"); 106 | }); 107 | 108 | test("should break connection on download limit exceeded", async () => { 109 | try { 110 | await axios.get("http://localhost:8001/limit"); 111 | } catch (error) { 112 | expect(error.code).toEqual("ECONNRESET"); 113 | 114 | return; 115 | } 116 | 117 | throw new Error(`HTTP: download limit exceeded`); 118 | }); 119 | 120 | test("should break connection on upload limit exceed", async () => { 121 | try { 122 | await axios.post("http://localhost:8001", limitPayload); 123 | } catch (error) { 124 | expect(error.code).toEqual("ECONNRESET"); 125 | 126 | return; 127 | } 128 | 129 | throw new Error(`HTTP: upload limit exceeded`); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/lib/net/Web3FunctionHttpClient.ts: -------------------------------------------------------------------------------- 1 | // Use undici client as node@20.http has keepAlive errors 2 | // See github issue: https://github.com/nodejs/node/issues/47130 3 | import { Agent, request } from "undici"; 4 | 5 | import { EventEmitter } from "events"; 6 | import { performance } from "perf_hooks"; 7 | import { Web3FunctionEvent } from "../types/Web3FunctionEvent"; 8 | const delay = (t: number) => new Promise((resolve) => setTimeout(resolve, t)); 9 | 10 | export class Web3FunctionHttpClient extends EventEmitter { 11 | private _debug: boolean; 12 | private _host: string; 13 | private _port: number; 14 | private _mountPath: string; 15 | private _isStopped = false; 16 | 17 | constructor(host: string, port: number, mountPath: string, debug = true) { 18 | super(); 19 | this._host = host; 20 | this._port = port; 21 | this._debug = debug; 22 | this._mountPath = mountPath; 23 | this.on("input_event", this._safeSend.bind(this)); 24 | } 25 | 26 | public async connect(timeout: number) { 27 | const retryInterval = 50; 28 | const end = performance.now() + timeout; 29 | let statusOk = false; 30 | let lastErrMsg = ""; 31 | let nbTries = 0; 32 | let connectTimeout = 1000; 33 | while (!statusOk && !this._isStopped && performance.now() < end) { 34 | nbTries++; 35 | try { 36 | const status = await new Promise(async (resolve, reject) => { 37 | const requestAbortController = new AbortController(); 38 | const timeoutId = setTimeout(() => { 39 | connectTimeout += 100; // gradually increase the timeout for each retry 40 | requestAbortController.abort(); 41 | reject(new Error(`Timeout after ${nbTries} tries`)); 42 | }, connectTimeout); 43 | try { 44 | const { statusCode } = await request( 45 | `${this._host}:${this._port}/${this._mountPath}`, 46 | { 47 | dispatcher: new Agent({ pipelining: 0 }), 48 | signal: requestAbortController.signal, 49 | } 50 | ); 51 | resolve(statusCode); 52 | } catch (err) { 53 | reject(err); 54 | } finally { 55 | clearTimeout(timeoutId); 56 | } 57 | }); 58 | statusOk = status === 200; 59 | this._log(`Connected to Web3FunctionHttpServer socket!`); 60 | } catch (err) { 61 | const errMsg = `${err.message} `; 62 | 63 | lastErrMsg = errMsg; 64 | await delay(retryInterval); 65 | } 66 | } 67 | 68 | // Current instance has been stopped before we could connect 69 | if (this._isStopped) throw new Error(`Disconnected`); 70 | 71 | if (!statusOk) { 72 | throw new Error( 73 | `Web3FunctionHttpClient unable to connect (timeout=${timeout}ms): ${lastErrMsg}` 74 | ); 75 | } 76 | } 77 | 78 | private async _safeSend(event: Web3FunctionEvent) { 79 | try { 80 | await this._send(event); 81 | } catch (error) { 82 | this.emit("error", error); 83 | } 84 | } 85 | 86 | private async _send(event: Web3FunctionEvent) { 87 | let res; 88 | let retry = 0; 89 | const maxRetry = 2; 90 | do { 91 | try { 92 | const { body } = await request( 93 | `${this._host}:${this._port}/${this._mountPath}`, 94 | { 95 | method: "POST", 96 | headers: { "content-type": "application/json" }, 97 | body: JSON.stringify(event), 98 | dispatcher: new Agent({ pipelining: 0 }), 99 | } 100 | ); 101 | res = body; 102 | } catch (err) { 103 | const errMsg = err.toString(); 104 | if (retry >= maxRetry) { 105 | throw new Error(`Web3FunctionHttpClient request error: ${errMsg}`); 106 | } else { 107 | retry++; 108 | this._log( 109 | `Web3FunctionHttpClient _send retry#${retry} request error: ${errMsg}` 110 | ); 111 | await delay(100); 112 | } 113 | } 114 | } while (!res); 115 | 116 | try { 117 | const event = (await res.json()) as Web3FunctionEvent; 118 | this._log(`Received Web3FunctionEvent: ${event.action}`); 119 | this.emit("output_event", event); 120 | } catch (err) { 121 | this._log(`Error parsing message: ${err.message}`); 122 | console.log(res.data); 123 | throw new Error(`Web3FunctionHttpClient response error: ${err.message}`); 124 | } 125 | } 126 | private _log(message: string) { 127 | if (this._debug) console.log(`Web3FunctionHttpClient: ${message}`); 128 | } 129 | 130 | public end() { 131 | if (!this._isStopped) { 132 | this._isStopped = true; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gelatonetwork/web3-functions-sdk", 3 | "version": "2.4.4", 4 | "description": "Gelato Automate Web3 Functions sdk", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/gelatodigital/web3-functions-sdk.git" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/gelatodigital/web3-functions-sdk/issues" 11 | }, 12 | "homepage": "https://docs.gelato.network/developer-services/web3-functions", 13 | "author": "Gelato Network", 14 | "license": "MIT", 15 | "keywords": [ 16 | "web3", 17 | "serverless", 18 | "blockchain", 19 | "smart-contract", 20 | "automation", 21 | "hardhat", 22 | "hardhat-plugin" 23 | ], 24 | "main": "dist/lib/index.js", 25 | "types": "dist/lib/types/index.d.ts", 26 | "files": [ 27 | "dist/lib/**/*", 28 | "dist/hardhat/**/*" 29 | ], 30 | "bin": { 31 | "w3f": "./dist/bin/index.js" 32 | }, 33 | "exports": { 34 | ".": "./dist/lib/index.js", 35 | "./net": "./dist/lib/net/index.js", 36 | "./runtime": "./dist/lib/runtime/index.js", 37 | "./provider": "./dist/lib/provider/index.js", 38 | "./builder": "./dist/lib/builder/index.js", 39 | "./uploader": "./dist/lib/uploader/index.js", 40 | "./loader": "./dist/lib/loader/index.js", 41 | "./binaries": "./dist/lib/binaries/index.js", 42 | "./hardhat-plugin": "./dist/hardhat/index.js", 43 | "./types": "./dist/lib/types/index.js" 44 | }, 45 | "typesVersions": { 46 | "*": { 47 | "*": [ 48 | "dist/lib/index.d.ts" 49 | ], 50 | "net": [ 51 | "dist/lib/net/index.d.ts" 52 | ], 53 | "provider": [ 54 | "dist/lib/provider/index.d.ts" 55 | ], 56 | "runtime": [ 57 | "dist/lib/runtime/index.d.ts" 58 | ], 59 | "builder": [ 60 | "dist/lib/builder/index.d.ts" 61 | ], 62 | "uploader": [ 63 | "dist/lib/uploader/index.d.ts" 64 | ], 65 | "loader": [ 66 | "dist/lib/loader/index.d.ts" 67 | ], 68 | "binaries": [ 69 | "dist/lib/binaries/index.d.ts" 70 | ], 71 | "hardhat-plugin": [ 72 | "dist/hardhat/index.d.ts" 73 | ] 74 | } 75 | }, 76 | "scripts": { 77 | "prebuild": "node -p \"'export const SDK_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/lib/version.ts", 78 | "build": "rm -rf dist && tsc --project tsconfig.build.json && yarn deps", 79 | "deps": "yarn link && yarn link @gelatonetwork/web3-functions-sdk", 80 | "format": "prettier --write '*/**/*.{js,json,md,ts}'", 81 | "format:check": "prettier --check '*/**/*.{js,json,md,ts}'", 82 | "lint": "eslint --cache .", 83 | "test": "ts-node src/bin/index.ts test", 84 | "test:unit": "node --experimental-vm-modules ./node_modules/.bin/jest src --verbose --detectOpenHandles --forceExit --silent=true", 85 | "benchmark": "ts-node src/bin/index.ts benchmark", 86 | "deploy": "ts-node src/bin/index.ts deploy", 87 | "fetch": "ts-node src/bin/index.ts fetch", 88 | "schema": "ts-node src/bin/index.ts schema" 89 | }, 90 | "engines": { 91 | "node": ">=18.0.0" 92 | }, 93 | "devDependencies": { 94 | "@changesets/cli": "^2.26.2", 95 | "@ethersproject/abi": "^5.7.0", 96 | "@jest/globals": "^29.7.0", 97 | "@tsconfig/recommended": "^1.0.1", 98 | "@types/jest": "^29.5.5", 99 | "@types/node": "^18", 100 | "@types/object-hash": "^3.0.2", 101 | "@types/signal-exit": "^3.0.1", 102 | "@typescript-eslint/eslint-plugin": "^5.40.0", 103 | "@typescript-eslint/parser": "^5.6.0", 104 | "axios-mock-adapter": "^1.21.5", 105 | "eslint": "^8.4.1", 106 | "eslint-config-prettier": "^8.3.0", 107 | "eslint-plugin-prettier": "^4.0.0", 108 | "hardhat": "^2.13.0", 109 | "husky": "^8.0.3", 110 | "jest": "^29.5.0", 111 | "prettier": "^2.3.2", 112 | "ts-jest": "^29.1.0", 113 | "ts-node": "^10.9.1", 114 | "typescript": "^4.7.0" 115 | }, 116 | "dependencies": { 117 | "@ethersproject/address": "^5.7.0", 118 | "@ethersproject/contracts": "^5.7.0", 119 | "@ethersproject/providers": "^5.7.2", 120 | "@ethersproject/units": "^5.7.0", 121 | "@types/dockerode": "^3.3.11", 122 | "ajv": "^8.11.0", 123 | "axios": "^1.6.8", 124 | "body-parser": "^1.20.1", 125 | "colors": "^1.4.0", 126 | "deep-object-diff": "^1.1.9", 127 | "deno-bin": "^1.44.4", 128 | "dockerode": "^3.3.4", 129 | "dotenv": "^16.0.3", 130 | "esbuild": "^0.17.4", 131 | "eth-rpc-errors": "^4.0.3", 132 | "express": "^4.18.2", 133 | "form-data": "^4.0.0", 134 | "ky": "^0.32.2", 135 | "pidusage": "^3.0.1", 136 | "semver": "^7.5.0", 137 | "signal-exit": "^3.0.7", 138 | "tar": "^6.1.12", 139 | "undici": "^6.6.2" 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/Web3FunctionDockerSandbox.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty */ 2 | import Docker, { ImageInfo } from "dockerode"; 3 | import path from "path"; 4 | import { Web3FunctionVersion } from "../../types"; 5 | import { Web3FunctionAbstractSandbox } from "./Web3FunctionAbstractSandbox"; 6 | 7 | const HTTP_PROXY_HOST = "host.docker.internal"; 8 | 9 | export class Web3FunctionDockerSandbox extends Web3FunctionAbstractSandbox { 10 | private _container?: Docker.Container; 11 | private _docker = new Docker(); 12 | private _denoImage = "denoland/deno:alpine-1.36.0"; 13 | 14 | protected async _stop(): Promise { 15 | if (!this._container) return; 16 | try { 17 | await this._container.kill({ 18 | signal: "SIGKILL", 19 | }); 20 | } catch (err) {} 21 | try { 22 | await this._container.remove(); 23 | } catch (err) {} 24 | } 25 | 26 | protected async _createImageIfMissing(image: string) { 27 | let images: ImageInfo[] = []; 28 | try { 29 | images = await this._docker.listImages({ 30 | filters: JSON.stringify({ reference: [image] }), 31 | }); 32 | } catch (err) {} 33 | 34 | if (images.length === 0) { 35 | this._log(`Creating docker image: ${image}`); 36 | const created = await this._docker.createImage({ fromImage: image }); 37 | await new Promise((resolve) => { 38 | created.on("data", (raw) => { 39 | const lines = raw.toString().split("\r\n"); 40 | lines.forEach((line) => { 41 | if (line === "") return; 42 | const data = JSON.parse(line); 43 | this._log(`${data.status} ${data.progress ?? ""}`); 44 | }); 45 | }); 46 | created.once("end", resolve); 47 | }); 48 | this._log(`Docker image created!`); 49 | } 50 | } 51 | 52 | protected async _start( 53 | script: string, 54 | version: Web3FunctionVersion, 55 | serverPort: number, 56 | mountPath: string, 57 | httpProxyPort: number, 58 | args: string[] 59 | ): Promise { 60 | const { dir, name, ext } = path.parse(script); 61 | const scriptName = `${name}${ext}`; 62 | const cmd = `deno`; 63 | 64 | let env: string[] = []; 65 | 66 | if (version === Web3FunctionVersion.V1_0_0) { 67 | env = [`WEB3_FUNCTION_SERVER_PORT=${serverPort.toString()}`]; 68 | } else { 69 | env = [ 70 | `WEB3_FUNCTION_SERVER_PORT=${serverPort.toString()}`, 71 | `WEB3_FUNCTION_MOUNT_PATH=${mountPath}`, 72 | ]; 73 | } 74 | 75 | const httpProxyUrl = `${HTTP_PROXY_HOST}:${httpProxyPort}`; 76 | env.push(`HTTP_PROXY=${httpProxyUrl}`); 77 | env.push(`HTTPS_PROXY=${httpProxyUrl}`); 78 | 79 | args.push(`/web3Function/${scriptName}`); 80 | 81 | // See docker create options: 82 | // https://docs.docker.com/engine/api/v1.37/#tag/Container/operation/ContainerCreate 83 | const createOptions = { 84 | ExposedPorts: { 85 | [`${serverPort.toString()}/tcp`]: {}, 86 | }, 87 | Env: env, 88 | Hostconfig: { 89 | Binds: [`${dir}:/web3Function/`], 90 | PortBindings: { 91 | [`${serverPort.toString()}/tcp`]: [ 92 | { HostPort: `${serverPort.toString()}` }, 93 | ], 94 | }, 95 | NetworkMode: "bridge", 96 | Memory: this._memoryLimit * 1024 * 1024, 97 | }, 98 | Tty: true, 99 | //StopTimeout: 10, 100 | Cmd: [cmd, ...args], 101 | Image: this._denoImage, 102 | }; 103 | 104 | let processExitCodeFunction; 105 | this._processExitCodePromise = new Promise((resolve) => { 106 | processExitCodeFunction = resolve; 107 | }); 108 | 109 | await this._createImageIfMissing(this._denoImage); 110 | this._container = await this._docker.createContainer(createOptions); 111 | const containerStream = await this._container.attach({ 112 | stream: true, 113 | stdout: true, 114 | stderr: true, 115 | }); 116 | containerStream.setEncoding("utf8"); 117 | containerStream.on("data", this._onStdoutData.bind(this)); 118 | containerStream.on("end", async () => { 119 | try { 120 | // Container has stopped 121 | const status = await this._container?.wait(); 122 | processExitCodeFunction(status.StatusCode); 123 | this._log(`Container exited with code=${status.StatusCode}`); 124 | } catch (err) { 125 | processExitCodeFunction(1); 126 | this._log(`Unable to get container exit code, error: ${err.message}`); 127 | } 128 | }); 129 | await this._container.start({}); 130 | } 131 | protected async _getMemoryUsage(): Promise { 132 | const stats = await this._container?.stats({ stream: false }); 133 | return stats?.memory_stats.usage ?? 0; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/lib/runtime/sandbox/Web3FunctionThreadSandbox.spec.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import { Web3FunctionVersion } from "../../types"; 4 | import { Web3FunctionThreadSandbox } from "./Web3FunctionThreadSandbox"; 5 | 6 | describe("Web3FunctionThreadSandbox", () => { 7 | const SCRIPT_FOLDER = path.join( 8 | process.cwd(), 9 | "src", 10 | "lib", 11 | "runtime", 12 | "sandbox", 13 | "__test__" 14 | ); 15 | 16 | test("pass correct arguments to runner", async () => { 17 | const runner = new Web3FunctionThreadSandbox( 18 | { 19 | memoryLimit: 10, 20 | }, 21 | false, 22 | false 23 | ); 24 | 25 | const serverPort = 8000; 26 | const mountPath = "./"; 27 | const httpProxyPort = 8080; 28 | 29 | await runner.start( 30 | path.join(SCRIPT_FOLDER, "simple.ts"), 31 | Web3FunctionVersion.V2_0_0, 32 | serverPort, 33 | mountPath, 34 | httpProxyPort, 35 | [] 36 | ); 37 | 38 | await runner.waitForProcessEnd(); 39 | 40 | const logs = runner.getLogs(); 41 | 42 | expect(logs.length).toBe(2); 43 | expect(logs[0]).toBe(serverPort.toString()); 44 | expect(logs[1]).toBe(mountPath); 45 | 46 | await runner.stop(); 47 | }); 48 | 49 | test("should throw on invalid env access", async () => { 50 | const runner = new Web3FunctionThreadSandbox( 51 | { 52 | memoryLimit: 10, 53 | }, 54 | false, 55 | false 56 | ); 57 | 58 | const serverPort = 8000; 59 | const mountPath = "./"; 60 | const httpProxyPort = 8080; 61 | 62 | await runner.start( 63 | path.join(SCRIPT_FOLDER, "not_allowed_env.ts"), 64 | Web3FunctionVersion.V2_0_0, 65 | serverPort, 66 | mountPath, 67 | httpProxyPort, 68 | [] 69 | ); 70 | 71 | await runner.waitForProcessEnd(); 72 | 73 | const logs = runner.getLogs(); 74 | 75 | expect(logs.length).toBe(1); 76 | expect(logs[0]).toBe("Passed"); 77 | 78 | await runner.stop(); 79 | }); 80 | 81 | test("should error out when memory exceeded", async () => { 82 | const runner = new Web3FunctionThreadSandbox( 83 | { 84 | memoryLimit: 10, 85 | }, 86 | false, 87 | false 88 | ); 89 | 90 | const serverPort = 8000; 91 | const mountPath = "./"; 92 | const httpProxyPort = 8080; 93 | 94 | await runner.start( 95 | path.join(SCRIPT_FOLDER, "exceed_memory_usage.ts"), 96 | Web3FunctionVersion.V2_0_0, 97 | serverPort, 98 | mountPath, 99 | httpProxyPort, 100 | [] 101 | ); 102 | 103 | await runner.waitForProcessEnd(); 104 | 105 | const logs = runner.getLogs(); 106 | 107 | expect(logs.length).toBeGreaterThan(0); 108 | expect(logs[0]).toMatch("Last few GCs"); 109 | 110 | await runner.stop(); 111 | }); 112 | 113 | test("should report memory usage", async () => { 114 | const runner = new Web3FunctionThreadSandbox( 115 | { 116 | memoryLimit: 10, 117 | }, 118 | false, 119 | false 120 | ); 121 | 122 | const serverPort = 8000; 123 | const mountPath = "./"; 124 | const httpProxyPort = 8080; 125 | 126 | await runner.start( 127 | path.join(SCRIPT_FOLDER, "memory_usage.ts"), 128 | Web3FunctionVersion.V2_0_0, 129 | serverPort, 130 | mountPath, 131 | httpProxyPort, 132 | [] 133 | ); 134 | 135 | await new Promise((r) => setTimeout(r, 1000)); 136 | 137 | const memory = await runner.getMemoryUsage(); 138 | await runner.waitForProcessEnd(); 139 | await runner.stop(); 140 | 141 | expect(memory).toBeGreaterThan(0); 142 | }); 143 | 144 | test("should ignore stop if already stopped", async () => { 145 | const runner = new Web3FunctionThreadSandbox( 146 | { 147 | memoryLimit: 10, 148 | }, 149 | false, 150 | false 151 | ); 152 | 153 | const serverPort = 8000; 154 | const mountPath = "./"; 155 | const httpProxyPort = 8080; 156 | 157 | await runner.start( 158 | path.join(SCRIPT_FOLDER, "simple.ts"), 159 | Web3FunctionVersion.V2_0_0, 160 | serverPort, 161 | mountPath, 162 | httpProxyPort, 163 | [] 164 | ); 165 | 166 | await runner.waitForProcessEnd(); 167 | await runner.stop(); 168 | await runner.stop(); 169 | }); 170 | 171 | test("should disable access to blacklisted host", async () => { 172 | const runner = new Web3FunctionThreadSandbox( 173 | { 174 | memoryLimit: 10, 175 | }, 176 | false, 177 | false 178 | ); 179 | 180 | const serverPort = 8000; 181 | const mountPath = "./"; 182 | const httpProxyPort = 8080; 183 | 184 | await runner.start( 185 | path.join(SCRIPT_FOLDER, "blacklisted_host.ts"), 186 | Web3FunctionVersion.V2_0_0, 187 | serverPort, 188 | mountPath, 189 | httpProxyPort, 190 | ["http://gelato.network"] 191 | ); 192 | 193 | await runner.waitForProcessEnd(); 194 | 195 | const logs = runner.getLogs(); 196 | 197 | expect(logs.length).toBeGreaterThan(0); 198 | expect(logs[0]).toBe("Passed"); 199 | 200 | await runner.stop(); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /src/lib/builder/Web3FunctionBuilder.spec.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { Web3FunctionBuilder } from "./Web3FunctionBuilder"; 3 | 4 | describe("Web3FunctionBuilder.build", () => { 5 | const TEST_FOLDER_BASE = path.join( 6 | process.cwd(), 7 | "src/lib/builder/__test__/" 8 | ); 9 | 10 | const buildTestPath = (folder: string): string => { 11 | return path.join(TEST_FOLDER_BASE, folder); 12 | }; 13 | 14 | const buildSchemaPath = (folder: string): string => { 15 | return path.join(buildTestPath(folder), "index.ts"); 16 | }; 17 | 18 | test("should fail when input path does not exist", async () => { 19 | const res = await Web3FunctionBuilder.build( 20 | buildSchemaPath("not-existing") 21 | ); 22 | 23 | expect(res.success).toBeFalsy(); 24 | if (res.success === false) { 25 | expect(res.error.message).toMatch("Missing Web3Function schema"); 26 | } 27 | }); 28 | 29 | test("should fail when input path does not have schema.json", async () => { 30 | const res = await Web3FunctionBuilder.build(buildSchemaPath("no-schema")); 31 | 32 | expect(res.success).toBeFalsy(); 33 | if (res.success === false) { 34 | expect(res.error.message).toMatch("Missing Web3Function schema"); 35 | } 36 | }); 37 | 38 | test("should fail when schema is missing a required field", async () => { 39 | const res = await Web3FunctionBuilder.build( 40 | buildSchemaPath("missing-required-field") 41 | ); 42 | 43 | expect(res.success).toBeFalsy(); 44 | if (res.success === false) { 45 | expect(res.error.message).toMatch("must have required property"); 46 | } 47 | }); 48 | 49 | test("should fail when schema major version does not match with the SDK version", async () => { 50 | const res = await Web3FunctionBuilder.build( 51 | buildSchemaPath("invalid-schema-version") 52 | ); 53 | 54 | expect(res.success).toBeFalsy(); 55 | if (res.success === false) { 56 | expect(res.error.message).toMatch( 57 | "must match the major version of the installed sdk" 58 | ); 59 | } 60 | }); 61 | 62 | test("should fail when schema memory config is invalid", async () => { 63 | const res = await Web3FunctionBuilder.build( 64 | buildSchemaPath("invalid-schema-memory") 65 | ); 66 | 67 | expect(res.success).toBeFalsy(); 68 | if (res.success === false) { 69 | expect(res.error.message).toMatch( 70 | "'memory' must be equal to one of the allowed values" 71 | ); 72 | } 73 | }); 74 | 75 | test("should fail when schema execution mode is invalid", async () => { 76 | const res = await Web3FunctionBuilder.build( 77 | buildSchemaPath("invalid-schema-execution-mode") 78 | ); 79 | 80 | expect(res.success).toBeFalsy(); 81 | if (res.success === false) { 82 | expect(res.error.message).toMatch( 83 | "'executionMode' must be equal to one of the allowed values [sequential|parallel]" 84 | ); 85 | } 86 | }); 87 | 88 | test("should fail when schema runtime config is invalid", async () => { 89 | const res = await Web3FunctionBuilder.build( 90 | buildSchemaPath("invalid-schema-runtime") 91 | ); 92 | 93 | expect(res.success).toBeFalsy(); 94 | if (res.success === false) { 95 | expect(res.error.message).toMatch( 96 | "'runtime' must be equal to one of the allowed values" 97 | ); 98 | } 99 | }); 100 | 101 | test("should fail when schema eventRetry config is invalid", async () => { 102 | const res = await Web3FunctionBuilder.build( 103 | buildSchemaPath("invalid-schema-event-retry") 104 | ); 105 | 106 | expect(res.success).toBeFalsy(); 107 | if (res.success === false) { 108 | expect(res.error.message).toMatch("'eventRetryInterval' must be >= 60"); 109 | expect(res.error.message).toMatch("'eventRetryTtl' must be <= 259200"); 110 | } 111 | }); 112 | 113 | test("should fail when schema timeout is invalid", async () => { 114 | const res = await Web3FunctionBuilder.build( 115 | buildSchemaPath("invalid-schema-timeout") 116 | ); 117 | 118 | expect(res.success).toBeFalsy(); 119 | if (res.success === false) { 120 | expect(res.error.message.includes("'timeout' must be")).toBeTruthy(); 121 | } 122 | }); 123 | 124 | test("should fail when schema userArgs include unknown types", async () => { 125 | const res = await Web3FunctionBuilder.build( 126 | buildSchemaPath("invalid-schema-userargs") 127 | ); 128 | 129 | expect(res.success).toBeFalsy(); 130 | if (res.success === false) { 131 | expect(res.error.message).toMatch( 132 | "must be equal to one of the allowed values" 133 | ); 134 | } 135 | }); 136 | 137 | test("should pass when schema is valid", async () => { 138 | const filePath = path.join(buildTestPath("valid-schema"), "index.js"); 139 | const res = await Web3FunctionBuilder.build( 140 | buildSchemaPath("valid-schema"), 141 | { 142 | filePath, 143 | sourcePath: path.join(buildTestPath("valid-schema"), "source.js"), 144 | } 145 | ); 146 | 147 | expect(res.success).toBeTruthy(); 148 | if (res.success) { 149 | expect(res.filePath).toEqual(filePath); 150 | } 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/hardhat/hre/W3fHardhatPlugin.ts: -------------------------------------------------------------------------------- 1 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 2 | 3 | import { 4 | Web3FunctionContextData, 5 | Web3FunctionContextDataBase, 6 | Web3FunctionOperation, 7 | Web3FunctionUserArgs, 8 | } from "../../lib"; 9 | import { Web3FunctionBuilder } from "../../lib/builder"; 10 | import { W3fDetails, Web3FunctionLoader } from "../../lib/loader"; 11 | import { Web3FunctionExecSuccess, Web3FunctionRunner } from "../../lib/runtime"; 12 | import { 13 | MAX_DOWNLOAD_LIMIT, 14 | MAX_REQUEST_LIMIT, 15 | MAX_RPC_LIMIT, 16 | MAX_STORAGE_LIMIT, 17 | MAX_UPLOAD_LIMIT, 18 | } from "../constants"; 19 | import { 20 | EthersProviderWrapper, 21 | getMultiChainProviderConfigs, 22 | } from "../provider"; 23 | 24 | export class W3fHardhatPlugin { 25 | private hre: HardhatRuntimeEnvironment; 26 | 27 | constructor(_hre: HardhatRuntimeEnvironment) { 28 | this.hre = _hre; 29 | } 30 | 31 | public get(_name: string) { 32 | const w3f = Web3FunctionLoader.load(_name, this.hre.config.w3f.rootDir); 33 | 34 | return new Web3FunctionHardhat(this.hre, w3f); 35 | } 36 | } 37 | 38 | export class Web3FunctionHardhat { 39 | private w3f: W3fDetails; 40 | private hre: HardhatRuntimeEnvironment; 41 | private provider: EthersProviderWrapper; 42 | 43 | constructor(_hre: HardhatRuntimeEnvironment, _w3f: W3fDetails) { 44 | this.w3f = _w3f; 45 | this.hre = _hre; 46 | this.provider = new EthersProviderWrapper(_hre.network.provider); 47 | } 48 | 49 | public async run( 50 | operation: T, 51 | override?: { 52 | storage?: { [key: string]: string }; 53 | userArgs?: Web3FunctionUserArgs; 54 | } 55 | ): Promise> { 56 | const userArgs = override?.userArgs ?? this.w3f.userArgs; 57 | const storage = override?.storage ?? this.w3f.storage; 58 | const secrets = this.w3f.secrets; 59 | const debug = this.hre.config.w3f.debug; 60 | const log = this.w3f.log; 61 | 62 | const buildRes = await Web3FunctionBuilder.build(this.w3f.path, { debug }); 63 | 64 | if (!buildRes.success) 65 | throw new Error(`Fail to build web3Function: ${buildRes.error}`); 66 | 67 | const runner = new Web3FunctionRunner(debug); 68 | runner.validateUserArgs(buildRes.schema.userArgs, userArgs); 69 | const web3FunctionVersion = buildRes.schema.web3FunctionVersion; 70 | 71 | const runtime: "docker" | "thread" = "thread"; 72 | const memory = buildRes.schema.memory; 73 | const timeout = buildRes.schema.timeout * 1000; 74 | const version = buildRes.schema.web3FunctionVersion; 75 | 76 | const options = { 77 | runtime, 78 | showLogs: true, 79 | memory, 80 | rpcLimit: MAX_RPC_LIMIT, 81 | timeout, 82 | downloadLimit: MAX_DOWNLOAD_LIMIT, 83 | uploadLimit: MAX_UPLOAD_LIMIT, 84 | requestLimit: MAX_REQUEST_LIMIT, 85 | storageLimit: MAX_STORAGE_LIMIT, 86 | web3FunctionVersion, 87 | }; 88 | const script = buildRes.filePath; 89 | 90 | const gelatoArgs = await this.getGelatoArgs(); 91 | const baseContext: Web3FunctionContextDataBase = { 92 | gelatoArgs, 93 | userArgs, 94 | secrets, 95 | storage, 96 | log, 97 | }; 98 | let context: Web3FunctionContextData; 99 | if (operation === "onFail") { 100 | //Todo: accept arguments 101 | context = { 102 | ...baseContext, 103 | onFailReason: "SimulationFailed", 104 | callData: [ 105 | { 106 | to: "0x0000000000000000000000000000000000000000", 107 | data: "0x00000000", 108 | }, 109 | ], 110 | } as Web3FunctionContextData; 111 | } else if (operation === "onSuccess") { 112 | context = { 113 | ...baseContext, 114 | } as Web3FunctionContextData; 115 | } else { 116 | context = { 117 | ...baseContext, 118 | } as Web3FunctionContextData; 119 | } 120 | 121 | const multiChainProviderConfig = await getMultiChainProviderConfigs( 122 | this.hre 123 | ); 124 | 125 | const res = await runner.run(operation, { 126 | script, 127 | context, 128 | options, 129 | version, 130 | multiChainProviderConfig, 131 | }); 132 | 133 | if (!res.success) 134 | throw new Error(`Fail to run web3 function: ${res.error.message}`); 135 | 136 | return res; 137 | } 138 | 139 | public async deploy() { 140 | const cid = await Web3FunctionBuilder.deploy(this.w3f.path); 141 | 142 | return cid; 143 | } 144 | 145 | public async getGelatoArgs(gasPriceOverride?: string) { 146 | const block = await this.provider.getBlock("latest"); 147 | const blockTime = block.timestamp; 148 | 149 | const chainId = 150 | this.hre.network.config.chainId ?? 151 | (await this.provider.getNetwork()).chainId; 152 | 153 | const gasPrice = 154 | gasPriceOverride ?? (await this.provider.getGasPrice()).toString(); 155 | 156 | return { blockTime, chainId, gasPrice }; 157 | } 158 | 159 | public getSecrets() { 160 | return this.w3f.secrets; 161 | } 162 | 163 | public getUserArgs() { 164 | return this.w3f.userArgs; 165 | } 166 | 167 | public getStorage() { 168 | return this.w3f.storage; 169 | } 170 | 171 | public getPath() { 172 | return this.w3f.path; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/lib/provider/Web3FunctionProxyProvider.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | import bodyParser from "body-parser"; 3 | import crypto from "crypto"; 4 | import { ethErrors, serializeError } from "eth-rpc-errors"; 5 | import express from "express"; 6 | import http from "http"; 7 | import { MultiChainProviderConfig } from "./types"; 8 | 9 | export class Web3FunctionProxyProvider { 10 | private _debug: boolean; 11 | private _host: string; 12 | private _mountPath: string; 13 | private _proxyUrl!: string; 14 | private _app: express.Application = express(); 15 | private _server: http.Server | undefined; 16 | private _isStopped = false; 17 | private _nbRpcCalls = 0; 18 | private _nbThrottledRpcCalls = 0; 19 | private _limit: number; 20 | private _whitelistedMethods = ["eth_chainId", "net_version"]; 21 | private _providers: Map; 22 | private _mainChainId: number; 23 | 24 | constructor( 25 | host: string, 26 | limit: number, 27 | mainChainId: number, 28 | multiChainProviderConfig: MultiChainProviderConfig, 29 | debug = true 30 | ) { 31 | this._mainChainId = mainChainId; 32 | this._host = host; 33 | this._debug = debug; 34 | this._limit = limit; 35 | this._mountPath = crypto.randomUUID(); 36 | this._providers = new Map(); 37 | this._instantiateProvider(multiChainProviderConfig); 38 | } 39 | 40 | protected async _checkRateLimit() { 41 | if (this._nbRpcCalls > this._limit) { 42 | // Reject requests when reaching hard limit 43 | this._log(`Too many requests, blocking rpc call`); 44 | this._nbThrottledRpcCalls++; 45 | throw ethErrors.rpc.limitExceeded(); 46 | } 47 | } 48 | 49 | protected async _requestHandler(req: express.Request, res: express.Response) { 50 | this._log(`RPC call: ${JSON.stringify(req.body)}`); 51 | const { method, params, id, jsonrpc } = req.body; 52 | const chainId = req.params.chainId 53 | ? parseInt(req.params.chainId) 54 | : this._mainChainId; 55 | 56 | try { 57 | // Reject invalid JsonRPC requests 58 | if (!method || !params) throw ethErrors.rpc.invalidRequest(); 59 | 60 | if (method == "nbRpcCallsRemaining") { 61 | const nbRpcCallsRemaining = Math.max( 62 | 0, 63 | this._limit - this.getNbRpcCalls().total 64 | ); 65 | res.send({ result: { nbRpcCallsRemaining }, id, jsonrpc }); 66 | return; 67 | } 68 | 69 | this._nbRpcCalls++; 70 | 71 | // Apply rate limiting for non whitelisted methods 72 | if (!this._whitelistedMethods.includes(method)) { 73 | await this._checkRateLimit(); 74 | } 75 | 76 | // Forward RPC call to internal provider 77 | try { 78 | const provider = this._providers.get(chainId); 79 | 80 | if (!provider) throw ethErrors.provider.chainDisconnected(); 81 | 82 | const result = await provider.send(method, params); 83 | // Send result as valid JsonRPC response 84 | res.send({ result, id, jsonrpc }); 85 | } catch (providerError) { 86 | // Extract internal provider error 87 | let parsedProviderError; 88 | if (providerError.body) { 89 | try { 90 | const jsonResponse = JSON.parse(providerError.body); 91 | parsedProviderError = jsonResponse.error; 92 | } catch (_err) { 93 | parsedProviderError = providerError; 94 | } 95 | throw parsedProviderError; 96 | } 97 | throw providerError; 98 | } 99 | } catch (_error) { 100 | // Standardizing RPC error before returning to the user 101 | // If the serializer cannot extract a valid error, it will fallback to: { code: -32603, message: 'Internal JSON-RPC error.'} 102 | const { code, message, data } = serializeError(_error); 103 | // Send result as valid JsonRPC error 104 | res.send({ id, jsonrpc, error: { code, message, data } }); 105 | } 106 | } 107 | 108 | private _instantiateProvider(multiChainProviders: MultiChainProviderConfig) { 109 | const chainIds: number[] = []; 110 | for (const [chainIdStr, provider] of Object.entries(multiChainProviders)) { 111 | const chainId = parseInt(chainIdStr); 112 | this._providers.set(chainId, provider); 113 | chainIds.push(chainId); 114 | } 115 | 116 | this._log(`Providers injected for chainIds: ${JSON.stringify(chainIds)}`); 117 | 118 | if (!chainIds.includes(this._mainChainId)) { 119 | throw new Error( 120 | `Proxy provider cannot be instantiated, default chainId ${this._mainChainId} doesn't have a provider configured` 121 | ); 122 | } 123 | } 124 | 125 | public async start(port = 3000): Promise { 126 | this._proxyUrl = `${this._host}:${port}/${this._mountPath}`; 127 | await new Promise((resolve) => { 128 | this._server = this._app.listen(port, () => { 129 | this._log(`Listening on: ${this._proxyUrl}`); 130 | resolve(); 131 | }); 132 | }); 133 | 134 | this._app.use(bodyParser.json()); 135 | this._app.post(`/${this._mountPath}/`, this._requestHandler.bind(this)); 136 | this._app.post( 137 | `/${this._mountPath}/:chainId`, 138 | this._requestHandler.bind(this) 139 | ); 140 | } 141 | 142 | public getNbRpcCalls(): { total: number; throttled: number } { 143 | return { 144 | total: this._nbRpcCalls, 145 | throttled: this._nbThrottledRpcCalls, 146 | }; 147 | } 148 | 149 | public getProxyUrl(): string { 150 | return this._proxyUrl; 151 | } 152 | 153 | private _log(message: string) { 154 | if (this._debug) console.log(`Web3FunctionProxyProvider: ${message}`); 155 | } 156 | 157 | public stop() { 158 | if (!this._isStopped) { 159 | this._isStopped = true; 160 | if (this._server) this._server.close(); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/lib/builder/Web3FunctionBuilder.ts: -------------------------------------------------------------------------------- 1 | import Ajv from "ajv"; 2 | import esbuild from "esbuild"; 3 | import fs from "node:fs"; 4 | import path from "node:path"; 5 | import { performance } from "perf_hooks"; 6 | import { Web3FunctionSchema } from "../types"; 7 | import { Web3FunctionUploader } from "../uploader"; 8 | import { SDK_VERSION } from "../version"; 9 | import web3FunctionSchema from "./web3function.schema.json"; 10 | 11 | const ajv = new Ajv({ messages: true, allErrors: true }); 12 | const web3FunctionSchemaValidator = ajv.compile(web3FunctionSchema); 13 | 14 | export type Web3FunctionBuildResult = 15 | | { 16 | success: true; 17 | filePath: string; 18 | sourcePath: string; 19 | schemaPath: string; 20 | fileSize: number; 21 | buildTime: number; 22 | schema: Web3FunctionSchema; 23 | } 24 | | { success: false; error: Error }; 25 | 26 | export class Web3FunctionBuilder { 27 | /** 28 | * Helper function to build and publish Web3Function to IPFS 29 | * 30 | * @param input web3FunctionFilePath 31 | * @returns string CID: Web3Function IPFS hash 32 | */ 33 | public static async deploy(input: string): Promise { 34 | const buildRes = await Web3FunctionBuilder.build(input); 35 | if (!buildRes.success) throw buildRes.error; 36 | 37 | return await Web3FunctionUploader.upload( 38 | buildRes.schemaPath, 39 | buildRes.filePath, 40 | buildRes.sourcePath 41 | ); 42 | } 43 | 44 | private static async _buildBundle( 45 | input: string, 46 | outfile: string, 47 | alias?: Record 48 | ) { 49 | // Build & bundle web3Function 50 | const options: esbuild.BuildOptions = { 51 | bundle: true, 52 | entryPoints: [input], 53 | absWorkingDir: process.cwd(), 54 | platform: "browser", 55 | target: "es2022", 56 | format: "esm", 57 | minify: true, 58 | inject: [path.join(__dirname, "../polyfill/XMLHttpRequest.js")], 59 | alias, 60 | outfile, 61 | }; 62 | 63 | await esbuild.build(options); 64 | } 65 | 66 | private static async _buildSource( 67 | input: string, 68 | outfile: string, 69 | alias?: Record 70 | ) { 71 | // Build & bundle js source file 72 | const options: esbuild.BuildOptions = { 73 | bundle: true, 74 | entryPoints: [input], 75 | absWorkingDir: process.cwd(), 76 | packages: "external", // exclude all npm packages 77 | target: "es2022", 78 | platform: "browser", 79 | format: "esm", 80 | alias, 81 | outfile, 82 | }; 83 | 84 | await esbuild.build(options); 85 | } 86 | 87 | private static async _validateSchema( 88 | input: string 89 | ): Promise { 90 | const hasSchema = fs.existsSync(input); 91 | if (!hasSchema) { 92 | throw new Error( 93 | `Web3FunctionSchemaError: Missing Web3Function schema at '${input}' 94 | Please create 'schema.json', default: 95 | { 96 | "web3FunctionVersion": "2.0.0", 97 | "runtime": "js-1.0", 98 | "memory": 128, 99 | "timeout": 30, 100 | "userArgs": {} 101 | }` 102 | ); 103 | } 104 | 105 | let schemaContent; 106 | try { 107 | schemaContent = fs.readFileSync(input).toString(); 108 | } catch (err) { 109 | throw new Error( 110 | `Web3FunctionSchemaError: Unable to read Web3Function schema at '${input}', ${err.message}` 111 | ); 112 | } 113 | 114 | let schemaBody; 115 | try { 116 | schemaBody = JSON.parse(schemaContent); 117 | } catch (err) { 118 | throw new Error( 119 | `Web3FunctionSchemaError: Invalid json schema at '${input}', ${err.message}` 120 | ); 121 | } 122 | 123 | const res = web3FunctionSchemaValidator(schemaBody); 124 | if (!res) { 125 | let errorParts = ""; 126 | if (web3FunctionSchemaValidator.errors) { 127 | errorParts = web3FunctionSchemaValidator.errors 128 | .map((validationErr) => { 129 | let msg = `\n - `; 130 | if (validationErr.instancePath === "/web3FunctionVersion") { 131 | msg += `'web3FunctionVersion' must match the major version of the installed sdk (${SDK_VERSION})`; 132 | if (validationErr.params.allowedValues) { 133 | msg += ` [${validationErr.params.allowedValues.join("|")}]`; 134 | } 135 | return msg; 136 | } else if (validationErr.instancePath) { 137 | msg += `'${validationErr.instancePath 138 | .replace("/", ".") 139 | .substring(1)}' `; 140 | } 141 | msg += `${validationErr.message}`; 142 | if (validationErr.params.allowedValues) { 143 | msg += ` [${validationErr.params.allowedValues.join("|")}]`; 144 | } 145 | return msg; 146 | }) 147 | .join(); 148 | } 149 | throw new Error( 150 | `Web3FunctionSchemaError: invalid ${input} ${errorParts}` 151 | ); 152 | } 153 | return schemaBody as Web3FunctionSchema; 154 | } 155 | 156 | public static async build( 157 | input: string, 158 | options?: { 159 | debug?: boolean; 160 | filePath?: string; 161 | sourcePath?: string; 162 | alias?: Record; 163 | } 164 | ): Promise { 165 | const { 166 | debug = false, 167 | filePath = path.join(process.cwd(), ".tmp", "index.js"), 168 | sourcePath = path.join(process.cwd(), ".tmp", "source.js"), 169 | alias, 170 | } = options ?? {}; 171 | 172 | try { 173 | const schemaPath = path.join(path.parse(input).dir, "schema.json"); 174 | const schema = await Web3FunctionBuilder._validateSchema(schemaPath); 175 | 176 | const start = performance.now(); 177 | await Promise.all([ 178 | Web3FunctionBuilder._buildBundle(input, filePath, alias), 179 | Web3FunctionBuilder._buildSource(input, sourcePath, alias), 180 | ]); 181 | const buildTime = performance.now() - start; // in ms 182 | 183 | const stats = fs.statSync(filePath); 184 | const fileSize = stats.size / 1024 / 1024; // size in mb 185 | 186 | return { 187 | success: true, 188 | schemaPath, 189 | sourcePath, 190 | filePath, 191 | fileSize, 192 | buildTime, 193 | schema, 194 | }; 195 | } catch (err) { 196 | if (debug) console.error(err); 197 | return { 198 | success: false, 199 | error: err, 200 | }; 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/lib/binaries/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 2 | import colors from "colors/safe"; 3 | import "dotenv/config"; 4 | import path from "path"; 5 | import { performance } from "perf_hooks"; 6 | 7 | import { 8 | MAX_DOWNLOAD_LIMIT, 9 | MAX_REQUEST_LIMIT, 10 | MAX_STORAGE_LIMIT, 11 | MAX_UPLOAD_LIMIT, 12 | } from "../../hardhat/constants"; 13 | import { Web3FunctionBuilder } from "../builder"; 14 | import { Web3FunctionLoader } from "../loader"; 15 | import { MultiChainProviderConfig } from "../provider"; 16 | import { 17 | Web3FunctionExec, 18 | Web3FunctionRunner, 19 | Web3FunctionRunnerPool, 20 | } from "../runtime"; 21 | import { 22 | Web3FunctionContextData, 23 | Web3FunctionContextDataBase, 24 | Web3FunctionOperation, 25 | } from "../types"; 26 | 27 | const delay = (t: number) => new Promise((resolve) => setTimeout(resolve, t)); 28 | 29 | if (!process.env.PROVIDER_URLS) { 30 | console.error(`Missing PROVIDER_URLS in .env file`); 31 | process.exit(); 32 | } 33 | 34 | const providerUrls = (process.env.PROVIDER_URLS as string).split(","); 35 | const web3FunctionPath = 36 | process.argv[3] ?? 37 | path.join(process.cwd(), "src", "web3-functions", "index.ts"); 38 | let operation: Web3FunctionOperation = "onRun"; 39 | let chainId = 11155111; 40 | let runtime: "docker" | "thread" = "thread"; 41 | let debug = false; 42 | let showLogs = false; 43 | let load = 10; 44 | let pool = 10; 45 | if (process.argv.length > 2) { 46 | process.argv.slice(3).forEach((arg) => { 47 | if (arg.startsWith("--debug")) { 48 | debug = true; 49 | } else if (arg.startsWith("--logs")) { 50 | showLogs = true; 51 | } else if (arg.startsWith("--runtime=")) { 52 | const type = arg.split("=")[1]; 53 | runtime = type === "docker" ? "docker" : "thread"; 54 | } else if (arg.startsWith("--chain-id")) { 55 | chainId = parseInt(arg.split("=")[1]) ?? chainId; 56 | } else if (arg.startsWith("--load")) { 57 | load = parseInt(arg.split("=")[1]) ?? load; 58 | } else if (arg.startsWith("--pool")) { 59 | pool = parseInt(arg.split("=")[1]) ?? pool; 60 | } else if (arg.startsWith("--onFail")) { 61 | operation = "onFail"; 62 | } else if (arg.startsWith("--onSuccess")) { 63 | operation = "onSuccess"; 64 | } 65 | }); 66 | } 67 | const OK = colors.green("✓"); 68 | const KO = colors.red("✗"); 69 | export default async function benchmark() { 70 | // Build Web3Function 71 | const buildRes = await Web3FunctionBuilder.build(web3FunctionPath, { 72 | debug, 73 | }); 74 | if (!buildRes.success) { 75 | console.log(`\nWeb3Function Build result:`); 76 | console.log(` ${KO} Error: ${buildRes.error.message}`); 77 | return; 78 | } 79 | 80 | // Load Web3Function details (userArgs, secrets, storage) 81 | const parsedPathParts = path.parse(web3FunctionPath).dir.split(path.sep); 82 | const w3fName = parsedPathParts.pop() ?? ""; 83 | const w3fRootDir = parsedPathParts.join(path.sep); 84 | const w3fDetails = Web3FunctionLoader.load(w3fName, w3fRootDir); 85 | const userArgs = w3fDetails.userArgs; 86 | const secrets = w3fDetails.secrets; 87 | const storage = w3fDetails.storage; 88 | const log = w3fDetails.log; 89 | 90 | // Prepare mock content for test 91 | const baseContext: Web3FunctionContextDataBase = { 92 | secrets, 93 | storage, 94 | gelatoArgs: { 95 | chainId, 96 | gasPrice: "10", 97 | }, 98 | userArgs, 99 | log, 100 | }; 101 | 102 | let context: Web3FunctionContextData; 103 | if (operation === "onFail") { 104 | //Todo: accept arguments 105 | context = { 106 | ...baseContext, 107 | onFailReason: "SimulationFailed", 108 | callData: [ 109 | { 110 | to: "0x0000000000000000000000000000000000000000", 111 | data: "0x00000000", 112 | }, 113 | ], 114 | }; 115 | } else if (operation === "onSuccess") { 116 | context = { 117 | ...baseContext, 118 | }; 119 | } else { 120 | context = { 121 | ...baseContext, 122 | }; 123 | } 124 | 125 | // Validate user args against schema 126 | if (Object.keys(buildRes.schema.userArgs).length > 0) { 127 | const runner = new Web3FunctionRunner(debug); 128 | console.log(`\nWeb3Function user args validation:`); 129 | try { 130 | runner.validateUserArgs(buildRes.schema.userArgs, userArgs); 131 | 132 | Object.keys(context.userArgs).forEach((key) => { 133 | console.log(` ${OK} ${key}:`, context.userArgs[key]); 134 | }); 135 | } catch (err) { 136 | console.log(` ${KO} ${err.message}`); 137 | return; 138 | } 139 | } 140 | 141 | const multiChainProviderConfig: MultiChainProviderConfig = {}; 142 | for (const url of providerUrls) { 143 | const provider = new StaticJsonRpcProvider(url); 144 | const chainId = (await provider.getNetwork()).chainId; 145 | multiChainProviderConfig[chainId] = provider; 146 | } 147 | 148 | // Run Web3Function 149 | const start = performance.now(); 150 | const memory = buildRes.schema.memory; 151 | const timeout = buildRes.schema.timeout * 1000; 152 | const version = buildRes.schema.web3FunctionVersion; 153 | const rpcLimit = 100; 154 | const options = { 155 | runtime, 156 | showLogs, 157 | memory, 158 | timeout, 159 | rpcLimit, 160 | downloadLimit: MAX_DOWNLOAD_LIMIT, 161 | uploadLimit: MAX_UPLOAD_LIMIT, 162 | requestLimit: MAX_REQUEST_LIMIT, 163 | storageLimit: MAX_STORAGE_LIMIT, 164 | }; 165 | const script = buildRes.filePath; 166 | const runner = new Web3FunctionRunnerPool(pool, debug); 167 | await runner.init(); 168 | const promises: Promise>[] = []; 169 | 170 | for (let i = 0; i < load; i++) { 171 | console.log(`#${i} Queuing Web3Function`); 172 | promises.push( 173 | runner.run(operation, { 174 | script, 175 | version, 176 | context, 177 | options, 178 | multiChainProviderConfig, 179 | }) 180 | ); 181 | await delay(100); 182 | } 183 | 184 | const results = await Promise.all(promises); 185 | const duration = (performance.now() - start) / 1000; 186 | 187 | console.log(`\nWeb3Function results:`); 188 | results.forEach((res, i) => { 189 | if (res.success) console.log(` ${OK} #${i} Success`); 190 | else console.log(` ${KO} #${i} Error:`, res.error); 191 | }); 192 | const nbSuccess = results.filter((res) => res.success).length; 193 | console.log(`\nBenchmark result:`); 194 | console.log(`- nb success: ${nbSuccess}/${load}`); 195 | console.log(`- duration: ${duration.toFixed()}s`); 196 | } 197 | -------------------------------------------------------------------------------- /src/lib/net/Web3FunctionHttpProxy.ts: -------------------------------------------------------------------------------- 1 | import http, { IncomingMessage, Server, ServerResponse } from "http"; 2 | import net from "net"; 3 | import { Duplex } from "stream"; 4 | 5 | interface Web3FunctionHttpProxyStats { 6 | nbRequests: number; 7 | nbThrottled: number; 8 | download: number; // in KB 9 | upload: number; // in KB 10 | } 11 | 12 | export class Web3FunctionHttpProxy { 13 | private _debug: boolean; 14 | private _isStopped = true; 15 | 16 | private readonly _maxDownload: number; 17 | private readonly _maxUpload: number; 18 | private readonly _maxRequests: number; 19 | 20 | private _totalDownload = 0; 21 | private _totalUpload = 0; 22 | private _totalRequests = 0; 23 | private _totalRequestsThrottled = 0; 24 | 25 | private _server: Server; 26 | 27 | constructor( 28 | maxDownloadSize: number, 29 | maxUploadSize: number, 30 | maxRequests: number, 31 | debug: boolean 32 | ) { 33 | this._debug = debug; 34 | 35 | this._maxDownload = maxDownloadSize; 36 | this._maxUpload = maxUploadSize; 37 | this._maxRequests = maxRequests; 38 | 39 | this._server = http.createServer(this._handleServer.bind(this)); 40 | this._server.on("connect", this._handleSecureServer.bind(this)); 41 | } 42 | 43 | private _handleServer(req: IncomingMessage, res: ServerResponse) { 44 | if (req.url === undefined) { 45 | this._log("Request doesn't include any URL"); 46 | res.writeHead(400, { "Content-Type": "text/plain" }); 47 | res.end("Bad request"); 48 | 49 | this._log(`Bad request received with no URL`); 50 | return; 51 | } 52 | 53 | if (this._totalRequests++ >= this._maxRequests) { 54 | res.writeHead(429, { "Content-Type": "text/plain" }); 55 | res.end("Too many requests"); 56 | 57 | this._log("Request limit exceeded"); 58 | this._totalRequestsThrottled++; 59 | return; 60 | } 61 | 62 | try { 63 | const reqUrl = new URL(req.url); 64 | 65 | this._log(`Request url is proxied: ${req.url}`); 66 | 67 | const options = { 68 | hostname: reqUrl.hostname, 69 | port: reqUrl.port, 70 | path: reqUrl.pathname + reqUrl.search, 71 | method: req.method, 72 | headers: req.headers, 73 | }; 74 | 75 | const serverConnection = http.request( 76 | options, 77 | (serverRes: IncomingMessage) => { 78 | serverRes.on("data", (chunk) => { 79 | this._totalDownload += chunk.length; 80 | if (this._totalDownload >= this._maxDownload) { 81 | this._log("Download limit exceeded"); 82 | serverConnection.destroy(); 83 | res.destroy(); 84 | this._totalRequestsThrottled++; 85 | } 86 | }); 87 | 88 | res.writeHead(serverRes.statusCode ?? 200, serverRes.headers); 89 | serverRes.pipe(res); 90 | } 91 | ); 92 | 93 | req.on("data", (chunk) => { 94 | this._totalUpload += chunk.length; 95 | if (this._totalUpload >= this._maxUpload) { 96 | this._log("Upload limit exceeded"); 97 | this._totalRequestsThrottled++; 98 | req.destroy(); 99 | } 100 | }); 101 | req.pipe(serverConnection); 102 | 103 | req.on("error", (err) => { 104 | this._log(`Connection error to W3F runner: ${err.message}`); 105 | }); 106 | 107 | serverConnection.on("error", (err) => { 108 | this._log(`Connection error to target: ${err.message}`); 109 | }); 110 | } catch (err) { 111 | this._log(`Error during handling proxy: ${err.message}`); 112 | return; 113 | } 114 | } 115 | 116 | private _handleSecureServer( 117 | req: IncomingMessage, 118 | socket: Duplex, 119 | head: Buffer 120 | ) { 121 | if (req.url === undefined) { 122 | this._log("Request doesn't include any URL"); 123 | socket.end(); 124 | return; 125 | } 126 | 127 | if (this._totalRequests++ >= this._maxRequests) { 128 | this._log("Request limit exceeded"); 129 | socket.end(); 130 | this._totalRequestsThrottled++; 131 | return; 132 | } 133 | 134 | try { 135 | const reqUrl = new URL(`https://${req.url}`); 136 | 137 | this._log(`Secure request url is proxied: ${reqUrl.toString()}`); 138 | 139 | const options = { 140 | port: reqUrl.port === "" ? 443 : parseInt(reqUrl.port), 141 | host: reqUrl.hostname, 142 | }; 143 | 144 | const serverSocket = net.connect(options, () => { 145 | socket.write( 146 | `HTTP/${req.httpVersion} 200 Connection Established\r\nProxy-Agent: Gelato-W3F-Proxy\r\n\r\n`, 147 | "utf-8", 148 | () => { 149 | serverSocket.write(head); 150 | serverSocket.pipe(socket); 151 | socket.pipe(serverSocket); 152 | } 153 | ); 154 | }); 155 | 156 | serverSocket.on("data", (data: Buffer) => { 157 | this._totalDownload += data.length; 158 | 159 | if (this._totalDownload > this._maxDownload) { 160 | this._log("Download limit exceeded"); 161 | req.destroy(); 162 | serverSocket.destroy(); 163 | this._totalRequestsThrottled++; 164 | } 165 | }); 166 | 167 | socket.on("data", (data: Buffer) => { 168 | this._totalUpload += data.length; 169 | 170 | if (this._totalUpload >= this._maxUpload) { 171 | this._log("Upload limit exceeded"); 172 | req.destroy(); 173 | serverSocket.destroy(); 174 | this._totalRequestsThrottled++; 175 | } 176 | }); 177 | 178 | socket.on("error", (err) => { 179 | this._log(`Socket error to W3F runner: ${err.message}`); 180 | serverSocket.end(); 181 | }); 182 | 183 | serverSocket.on("error", (err) => { 184 | this._log(`Socket error to target: ${err.message}`); 185 | socket.end(); 186 | }); 187 | } catch (err) { 188 | this._log(`Error during handling HTTPs proxy: ${err.message}`); 189 | } 190 | } 191 | 192 | private _log(message: string) { 193 | if (this._debug) console.log(`Web3FunctionHttpProxy: ${message}`); 194 | } 195 | 196 | public start(port = 3000) { 197 | this._server 198 | .listen(port, () => { 199 | this._log(`Started listening on ${port}`); 200 | this._isStopped = false; 201 | }) 202 | .on("error", (err) => { 203 | this._log(`Proxy server cannot be started: ${err}`); 204 | this.stop(); 205 | }); 206 | } 207 | 208 | public stop() { 209 | if (!this._isStopped) { 210 | this._isStopped = true; 211 | if (this._server) this._server.close(); 212 | } 213 | } 214 | 215 | public getStats(): Web3FunctionHttpProxyStats { 216 | return { 217 | nbRequests: this._totalRequests, 218 | nbThrottled: this._totalRequestsThrottled, 219 | download: this._totalDownload / 1024, 220 | upload: this._totalUpload / 1024, 221 | }; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/lib/provider/Web3FunctionProxyProvider.spec.ts: -------------------------------------------------------------------------------- 1 | import { Web3FunctionProxyProvider } from "./Web3FunctionProxyProvider"; 2 | import { MultiChainProviderConfig } from "./types"; 3 | 4 | import { StaticJsonRpcProvider } from "@ethersproject/providers"; 5 | import { Agent, request } from "undici"; 6 | 7 | describe("Web3FunctionProxyProvider", () => { 8 | enum TestChainIds { 9 | Sepolia = 11155111, 10 | Amoy = 80002, 11 | ArbSepolia = 421614, 12 | } 13 | enum TestChainProviders { 14 | Sepolia = "https://rpc.ankr.com/eth_sepolia", 15 | Amoy = "https://rpc.ankr.com/polygon_amoy", 16 | ArbSepolia = "https://sepolia-rollup.arbitrum.io/rpc", 17 | } 18 | 19 | let proxyProvider: Web3FunctionProxyProvider; 20 | let multiChainProviderConfig: MultiChainProviderConfig; 21 | 22 | const proxyProviderHost = "http://127.0.0.1"; 23 | let proxyProviderPort: number; 24 | const rpcLimit = 5; 25 | 26 | beforeAll(() => { 27 | // proxyProviderPort = await Web3FunctionNetHelper.getAvailablePort(); 28 | proxyProviderPort = 3000; 29 | 30 | multiChainProviderConfig = { 31 | [TestChainIds.Sepolia]: new StaticJsonRpcProvider( 32 | TestChainProviders.Sepolia 33 | ), 34 | [TestChainIds.Amoy]: new StaticJsonRpcProvider(TestChainProviders.Amoy), 35 | [TestChainIds.ArbSepolia]: new StaticJsonRpcProvider( 36 | TestChainProviders.ArbSepolia 37 | ), 38 | }; 39 | }); 40 | 41 | beforeEach(async () => { 42 | proxyProvider = new Web3FunctionProxyProvider( 43 | proxyProviderHost, 44 | rpcLimit, 45 | TestChainIds.Sepolia, 46 | multiChainProviderConfig, 47 | false 48 | ); 49 | 50 | await proxyProvider.start(proxyProviderPort); 51 | }); 52 | 53 | afterEach(() => { 54 | proxyProvider.stop(); 55 | }); 56 | 57 | test("proxy provider url", () => { 58 | const testAddress = `${proxyProviderHost}:${proxyProviderPort}`; 59 | 60 | expect(proxyProvider.getProxyUrl().includes(testAddress)).toBeTruthy(); 61 | }); 62 | 63 | test("should reject invalid request", async () => { 64 | const { body } = await request(proxyProvider.getProxyUrl(), { 65 | method: "POST", 66 | headers: { "content-type": "application/json" }, 67 | body: JSON.stringify({ 68 | id: 0, 69 | jsonrpc: "2.0", 70 | }), 71 | dispatcher: new Agent({ pipelining: 0 }), 72 | }); 73 | 74 | const response = (await body.json()) as any; 75 | expect(response.error).toBeDefined(); 76 | expect(response.error.message).toBeDefined(); 77 | 78 | expect( 79 | response.error.message.includes("not a valid Request object.") 80 | ).toBeTruthy(); 81 | }); 82 | 83 | test("should rate limit exceeding requests", async () => { 84 | const numRequests = rpcLimit * 2; 85 | 86 | const limitingRequests = Array.from({ length: rpcLimit * 2 }, () => 87 | request(proxyProvider.getProxyUrl(), { 88 | method: "POST", 89 | headers: { "content-type": "application/json" }, 90 | body: JSON.stringify({ 91 | id: 0, 92 | jsonrpc: "2.0", 93 | method: "eth_getBlockByNumber", 94 | params: ["latest", false], 95 | }), 96 | dispatcher: new Agent({ pipelining: 0 }), 97 | }) 98 | .then(({ body }) => body.json()) 99 | .then((response: any) => { 100 | let fulfilled = true; 101 | if ( 102 | response.error && 103 | response.error.message.includes("Request limit exceeded") 104 | ) { 105 | fulfilled = false; 106 | } 107 | 108 | return { fulfilled }; 109 | }) 110 | .catch(() => { 111 | const fulfilled = false; 112 | return { fulfilled }; 113 | }) 114 | ); 115 | 116 | const results = await Promise.all(limitingRequests); 117 | const numFulfilled = results.filter((result) => result.fulfilled).length; 118 | const numUnfulfilled = results.filter((result) => !result.fulfilled).length; 119 | 120 | expect(numFulfilled).toEqual(rpcLimit); 121 | expect(numUnfulfilled).toEqual(numRequests - rpcLimit); 122 | }, 20_000); 123 | 124 | test("should not rate limit whitelisted methods", async () => { 125 | const numRequests = rpcLimit * 2; 126 | 127 | const limitingRequests = Array.from({ length: rpcLimit * 2 }, () => 128 | request(proxyProvider.getProxyUrl(), { 129 | method: "POST", 130 | headers: { "content-type": "application/json" }, 131 | body: JSON.stringify({ 132 | id: 0, 133 | jsonrpc: "2.0", 134 | method: "eth_chainId", 135 | params: [], 136 | }), 137 | dispatcher: new Agent({ pipelining: 0 }), 138 | }) 139 | .then(({ body }) => body.json()) 140 | .then((response: any) => { 141 | let fulfilled = true; 142 | if ( 143 | response.error && 144 | response.error.message.includes("Request limit exceeded") 145 | ) { 146 | fulfilled = false; 147 | } 148 | 149 | return { fulfilled }; 150 | }) 151 | .catch(() => { 152 | const fulfilled = false; 153 | return { fulfilled }; 154 | }) 155 | ); 156 | 157 | const results = await Promise.all(limitingRequests); 158 | const numFulfilled = results.filter((result) => result.fulfilled).length; 159 | const numUnfulfilled = results.filter((result) => !result.fulfilled).length; 160 | 161 | expect(numFulfilled).toEqual(numRequests); 162 | expect(numUnfulfilled).toEqual(0); 163 | }); 164 | 165 | test("should return provider error", async () => { 166 | const { body } = await request(proxyProvider.getProxyUrl(), { 167 | method: "POST", 168 | headers: { "content-type": "application/json" }, 169 | body: JSON.stringify({ 170 | id: 0, 171 | jsonrpc: "2.0", 172 | method: "eth_noRequest", 173 | params: [], 174 | }), 175 | dispatcher: new Agent({ pipelining: 0 }), 176 | }); 177 | const response = (await body.json()) as any; 178 | 179 | expect(response.error).toBeDefined(); 180 | expect(response.error.message).toBeDefined(); 181 | 182 | expect(response.error.message.includes("does not exist")).toBeTruthy(); 183 | }); 184 | 185 | test("should return original error data", async () => { 186 | const { body } = await request( 187 | `${proxyProvider.getProxyUrl()}/${TestChainIds.ArbSepolia}`, 188 | { 189 | method: "POST", 190 | headers: { "content-type": "application/json" }, 191 | body: JSON.stringify({ 192 | id: 0, 193 | jsonrpc: "2.0", 194 | method: "eth_call", 195 | params: [ 196 | { 197 | to: "0xac9f91277ccbb5d270e27246b203b221023a0e06", 198 | data: "0x7894e0b0000000000000000000000000000000000000000000000000000000000000000a", 199 | }, 200 | "latest", 201 | ], 202 | }), 203 | dispatcher: new Agent({ pipelining: 0 }), 204 | } 205 | ); 206 | const response = (await body.json()) as any; 207 | 208 | expect(response.error).toBeDefined(); 209 | expect(response.error.message).toBeDefined(); 210 | expect(response.error.data).toBeDefined(); 211 | 212 | expect(response.error.data.originalError.data).toEqual( 213 | "0x110b3655000000000000000000000000000000000000000000000000000000000000000a" 214 | ); 215 | }); 216 | 217 | test("should respond with main chain when chainId is not provided", async () => { 218 | const { body } = await request(proxyProvider.getProxyUrl(), { 219 | method: "POST", 220 | headers: { "content-type": "application/json" }, 221 | body: JSON.stringify({ 222 | id: 0, 223 | jsonrpc: "2.0", 224 | method: "eth_chainId", 225 | params: [], 226 | }), 227 | dispatcher: new Agent({ pipelining: 0 }), 228 | }); 229 | const mainChainIdResponse = (await body.json()) as any; 230 | 231 | const { body: body2 } = await request( 232 | `${proxyProvider.getProxyUrl()}/${TestChainIds.Amoy}`, 233 | { 234 | method: "POST", 235 | headers: { "content-type": "application/json" }, 236 | body: JSON.stringify({ 237 | id: 0, 238 | jsonrpc: "2.0", 239 | method: "eth_chainId", 240 | params: [], 241 | }), 242 | dispatcher: new Agent({ pipelining: 0 }), 243 | } 244 | ); 245 | const chainIdResponse = (await body2.json()) as any; 246 | 247 | const parsedMainChainId = parseInt( 248 | mainChainIdResponse.result.substring(2), 249 | 16 250 | ); 251 | const parsedChainId = parseInt(chainIdResponse.result.substring(2), 16); 252 | 253 | expect(parsedMainChainId).toEqual(TestChainIds.Sepolia); 254 | expect(parsedChainId).toEqual(TestChainIds.Amoy); 255 | }); 256 | 257 | test("should report RPC calls correctly", async () => { 258 | const numRequests = rpcLimit * 2; 259 | 260 | const limitingRequests = Array.from({ length: rpcLimit * 2 }, () => 261 | request(proxyProvider.getProxyUrl(), { 262 | method: "POST", 263 | headers: { "content-type": "application/json" }, 264 | body: JSON.stringify({ 265 | id: 0, 266 | jsonrpc: "2.0", 267 | method: "eth_getBlockByNumber", 268 | params: ["latest", false], 269 | }), 270 | dispatcher: new Agent({ pipelining: 0 }), 271 | }) 272 | ); 273 | 274 | await Promise.all(limitingRequests); 275 | 276 | const rpcStats = proxyProvider.getNbRpcCalls(); 277 | 278 | expect(rpcStats.total).toEqual(numRequests); 279 | expect(rpcStats.throttled).toEqual(numRequests - rpcLimit); 280 | }, 20_000); 281 | }); 282 | -------------------------------------------------------------------------------- /src/lib/uploader/Web3FunctionUploader.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import MockAdapter from "axios-mock-adapter"; 3 | import fs from "node:fs"; 4 | import fsp, { constants } from "node:fs/promises"; 5 | import path from "node:path"; 6 | import tar from "tar"; 7 | import { Web3FunctionUploader } from "./Web3FunctionUploader"; 8 | 9 | const OPS_API_BASE = "https://api.gelato.digital/automate/users"; 10 | 11 | describe("Web3FunctionUploader", () => { 12 | let mockUserApi: MockAdapter; 13 | const TEST_CID = "QmYDtW34NgZEppbR5GkGsXEkEkhT87nwX5RxiiSkzVRwb2"; 14 | 15 | const TEST_FOLDER_BASE = path.join( 16 | process.cwd(), 17 | "src/lib/uploader/__test__/" 18 | ); 19 | 20 | const buildTestPath = (folder: string): string => { 21 | return path.join(TEST_FOLDER_BASE, folder); 22 | }; 23 | 24 | const buildTestTempPath = (folder: string): string => { 25 | return buildTestPath(`.temp_${folder}`); 26 | }; 27 | 28 | const buildSchemaPath = (folder: string): string => { 29 | return path.join(buildTestPath(folder), "index.ts"); 30 | }; 31 | 32 | beforeAll(() => { 33 | mockUserApi = new MockAdapter(axios, { 34 | onNoMatch: "throwException", 35 | }); 36 | }); 37 | 38 | afterEach(() => { 39 | mockUserApi.reset(); 40 | }); 41 | 42 | // Extract 43 | const prepareExtractTest = async (folder: string): Promise => { 44 | const testFolder = buildTestPath(folder); 45 | const originalArchive = path.join(testFolder, `${TEST_CID}.tgz`); 46 | 47 | const tempFolder = buildTestTempPath(folder); 48 | await fsp.mkdir(tempFolder); 49 | 50 | const testArchive = path.join(tempFolder, `${TEST_CID}.tgz`); 51 | await fsp.copyFile(originalArchive, testArchive); 52 | 53 | return testArchive; 54 | }; 55 | const cleanupExtractTest = async (folder: string) => { 56 | await fsp.rm(buildTestTempPath(folder), { recursive: true, force: true }); 57 | }; 58 | 59 | test("extract should fail for invalid compressed file with missing schema", async () => { 60 | const testArchive = await prepareExtractTest("no-schema-tar"); 61 | 62 | try { 63 | await Web3FunctionUploader.extract(testArchive); 64 | fail("No schema TAR extracted"); 65 | } catch (error) { 66 | expect(error.message).toMatch("ENOENT"); 67 | } 68 | 69 | cleanupExtractTest("no-schema-tar"); 70 | }); 71 | 72 | test("extracted files should be within cid directory", async () => { 73 | // Prepare test 74 | const testArchive = await prepareExtractTest("valid-tar"); 75 | 76 | // Test 77 | await Web3FunctionUploader.extract(testArchive); 78 | await fsp.access(path.join(buildTestTempPath("valid-tar"), TEST_CID)); 79 | 80 | // Cleanup test 81 | cleanupExtractTest("valid-tar"); 82 | }); 83 | 84 | test("should not extract unknown file from archive", async () => { 85 | const testArchive = await prepareExtractTest("extra-file"); 86 | 87 | await Web3FunctionUploader.extract(testArchive); 88 | await expect( 89 | fsp.access( 90 | path.join( 91 | buildTestTempPath("extra-file"), 92 | TEST_CID, 93 | "extraschema.json" 94 | ), 95 | constants.F_OK 96 | ) 97 | ).rejects.toThrow(); 98 | 99 | cleanupExtractTest("extra-file"); 100 | }); 101 | 102 | // Compress 103 | const prepareCompressTest = async (folder: string): Promise => { 104 | const testFolder = buildTestPath(folder); 105 | const originalArchive = path.join(testFolder, `${TEST_CID}.tgz`); 106 | 107 | const tempFolder = buildTestTempPath(folder); 108 | await fsp.mkdir(tempFolder); 109 | 110 | await tar.x({ file: originalArchive, cwd: tempFolder }); 111 | 112 | return path.join(tempFolder, "web3Function"); 113 | }; 114 | 115 | test("compress should fail when build path could not be found", async () => { 116 | const nonExistingBuildPath = buildTestPath("non-existing"); 117 | const nonExistingSchemaPath = buildSchemaPath("non-existing"); 118 | 119 | try { 120 | await Web3FunctionUploader.compress( 121 | nonExistingBuildPath, 122 | nonExistingSchemaPath, 123 | path.join(nonExistingBuildPath, "index.js") 124 | ); 125 | 126 | fail("Compressed with non-existing build path"); 127 | } catch (error) { 128 | expect(error.message).toMatch("build file not found at path"); 129 | } 130 | }); 131 | 132 | test("compress should fail when schema file could not be found", async () => { 133 | // Prepare the test files 134 | const buildPath = await prepareCompressTest("no-schema-tar"); 135 | 136 | try { 137 | await Web3FunctionUploader.compress( 138 | path.join(buildPath, "index.js"), 139 | path.join(buildPath, "schema.json"), 140 | path.join(buildPath, "source.js") 141 | ); 142 | 143 | fail("Compressed with non-existing schema file"); 144 | } catch (error) { 145 | expect(error.message).toMatch("Schema not found at path"); 146 | } 147 | 148 | cleanupExtractTest("no-schema-tar"); 149 | }); 150 | 151 | // Fetch 152 | test("fetch should fail when User API could not found the CID", async () => { 153 | const cid = "some-invalid-cid"; 154 | mockUserApi.onGet(`${OPS_API_BASE}/users/web3-function/${cid}`).reply( 155 | 404, 156 | JSON.stringify({ 157 | message: "Web3Function not found", 158 | }) 159 | ); 160 | 161 | try { 162 | await Web3FunctionUploader.fetch(cid); 163 | fail("Invalid CID is fetched"); 164 | } catch (error) { 165 | expect(error.message).toMatch("404 Web3Function not found"); 166 | } 167 | }); 168 | 169 | test("fetched compressed W3F should be stored on the tmp folder", async () => { 170 | mockUserApi 171 | .onGet(`${OPS_API_BASE}/users/web3-function/${TEST_CID}`) 172 | .reply(function () { 173 | return [ 174 | 200, 175 | fs.createReadStream( 176 | path.join(buildTestPath("valid-tar"), `${TEST_CID}.tgz`) 177 | ), 178 | ]; 179 | }); 180 | 181 | const expectedPath = `.tmp/${TEST_CID}.tgz`; 182 | const testPath = await Web3FunctionUploader.fetch(TEST_CID); 183 | expect(testPath).toMatch(expectedPath); 184 | 185 | await fsp.access(path.join(process.cwd(), expectedPath)); 186 | return; 187 | }); 188 | 189 | test("fetched compressed W3F should be stored on the specified folder", async () => { 190 | mockUserApi 191 | .onGet(`${OPS_API_BASE}/users/web3-function/${TEST_CID}`) 192 | .reply(function () { 193 | return [ 194 | 200, 195 | fs.createReadStream( 196 | path.join(buildTestPath("valid-tar"), `${TEST_CID}.tgz`) 197 | ), 198 | ]; 199 | }); 200 | 201 | const expectedPath = `.tmp/my-test/${TEST_CID}.tgz`; 202 | const testPath = await Web3FunctionUploader.fetch( 203 | TEST_CID, 204 | path.join(process.cwd(), expectedPath) 205 | ); 206 | expect(testPath).toMatch(expectedPath); 207 | 208 | await fsp.access(path.join(process.cwd(), expectedPath)); 209 | return; 210 | }); 211 | 212 | // Fetch schema 213 | test("fetching schema should fail for non-existing schema file", async () => { 214 | mockUserApi 215 | .onGet(`${OPS_API_BASE}/users/web3-function/${TEST_CID}`) 216 | .reply(function () { 217 | return [ 218 | 200, 219 | fs.createReadStream( 220 | path.join(buildTestPath("no-schema-tar"), `${TEST_CID}.tgz`) 221 | ), 222 | ]; 223 | }); 224 | 225 | try { 226 | await Web3FunctionUploader.fetchSchema(TEST_CID); 227 | fail("W3F with no-schema fetched"); 228 | } catch (error) { 229 | expect(error.message).toMatch("ENOENT"); 230 | } 231 | }); 232 | 233 | test("fetching schema should fail for malformed schema file", async () => { 234 | mockUserApi 235 | .onGet(`${OPS_API_BASE}/users/web3-function/${TEST_CID}`) 236 | .reply(function () { 237 | return [ 238 | 200, 239 | fs.createReadStream( 240 | path.join(buildTestPath("malformed-schema-tar"), `${TEST_CID}.tgz`) 241 | ), 242 | ]; 243 | }); 244 | 245 | try { 246 | await Web3FunctionUploader.fetchSchema(TEST_CID); 247 | fail("W3F with no-schema fetched"); 248 | } catch (error) { 249 | expect(error.message).toMatch("Unexpected token"); 250 | } 251 | }); 252 | 253 | test("fetched function data should be removed after fetching schema", async () => { 254 | mockUserApi 255 | .onGet(`${OPS_API_BASE}/users/web3-function/${TEST_CID}`) 256 | .reply(function () { 257 | return [ 258 | 200, 259 | fs.createReadStream( 260 | path.join(buildTestPath("valid-tar"), `${TEST_CID}.tgz`) 261 | ), 262 | ]; 263 | }); 264 | 265 | const expectedPath = `.tmp/${TEST_CID}.tgz`; 266 | 267 | const schema = await Web3FunctionUploader.fetchSchema(TEST_CID); 268 | 269 | try { 270 | await fsp.access(path.join(process.cwd(), expectedPath)); 271 | fail("Fetched W3F not removed after schema"); 272 | } catch (error) { 273 | expect(error.message).toMatch("ENOENT"); 274 | } 275 | 276 | expect(schema.web3FunctionVersion).toBeDefined(); 277 | }); 278 | 279 | // Upload 280 | test("upload should return the CID of the W3F", async () => { 281 | const tempPath = await prepareCompressTest("valid-tar"); 282 | 283 | mockUserApi.onPost(`${OPS_API_BASE}/users/web3-function`).reply( 284 | 200, 285 | JSON.stringify({ 286 | cid: "my-cid", 287 | }) 288 | ); 289 | 290 | const cid = await Web3FunctionUploader.upload( 291 | path.join(tempPath, "schema.json"), 292 | path.join(tempPath, "index.js"), 293 | path.join(tempPath, "source.js") 294 | ); 295 | 296 | expect(cid).toBe("my-cid"); 297 | 298 | cleanupExtractTest("valid-tar"); 299 | }); 300 | }); 301 | -------------------------------------------------------------------------------- /src/lib/Web3Function.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "@ethersproject/bignumber"; 2 | import { diff } from "deep-object-diff"; 3 | import { Web3FunctionHttpServer } from "./net/Web3FunctionHttpServer"; 4 | import { Web3FunctionMultiChainProvider } from "./provider/Web3FunctionMultiChainProvider"; 5 | import { Web3FunctionResultCallData } from "./types"; 6 | import { 7 | Web3FunctionContext, 8 | Web3FunctionContextDataBase, 9 | Web3FunctionOnFailContextData, 10 | Web3FunctionOnRunContextData, 11 | Web3FunctionOnSuccessContextData, 12 | } from "./types/Web3FunctionContext"; 13 | import { Web3FunctionEvent } from "./types/Web3FunctionEvent"; 14 | import { 15 | BaseRunHandler, 16 | EventRunHandler, 17 | FailHandler, 18 | RunHandler, 19 | SuccessHandler, 20 | } from "./types/Web3FunctionHandler"; 21 | 22 | export class Web3Function { 23 | private static Instance?: Web3Function; 24 | private static _debug = false; 25 | private _server: Web3FunctionHttpServer; 26 | private _onRun?: RunHandler; 27 | private _onSuccess?: SuccessHandler; 28 | private _onFail?: FailHandler; 29 | 30 | constructor() { 31 | // Register global Unhandled Promise rejection catching 32 | globalThis.addEventListener("unhandledrejection", (e) => { 33 | console.log("Unhandled promise rejection at:", e.promise); 34 | this._exit(251, true); 35 | }); 36 | 37 | const port = Number(Deno.env.get("WEB3_FUNCTION_SERVER_PORT") ?? 80); 38 | const mountPath = Deno.env.get("WEB3_FUNCTION_MOUNT_PATH"); 39 | this._server = new Web3FunctionHttpServer( 40 | port, 41 | mountPath, 42 | Web3Function._debug, 43 | this._onFunctionEvent.bind(this) 44 | ); 45 | } 46 | 47 | private async _onFunctionEvent( 48 | event: Web3FunctionEvent 49 | ): Promise { 50 | if (event?.action === "start") { 51 | const prevStorage = { ...event.data.context.storage }; 52 | 53 | try { 54 | const { result, ctxData } = 55 | event.data.operation === "onSuccess" 56 | ? await this._invokeOnSuccess(event.data.context) 57 | : event.data.operation === "onFail" 58 | ? await this._invokeOnFail(event.data.context) 59 | : await this._invokeOnRun(event.data.context); 60 | 61 | const { difference, state } = this._compareStorage( 62 | prevStorage, 63 | ctxData.storage 64 | ); 65 | 66 | return { 67 | action: "result", 68 | data: { 69 | result, 70 | storage: { 71 | state, 72 | storage: ctxData.storage, 73 | diff: difference, 74 | }, 75 | callbacks: { 76 | onFail: this._onFail !== undefined, 77 | onSuccess: this._onSuccess !== undefined, 78 | }, 79 | }, 80 | }; 81 | } catch (error) { 82 | return { 83 | action: "error", 84 | data: { 85 | error: { 86 | name: error.name, 87 | message: `${error.name}: ${error.message}`, 88 | }, 89 | storage: { 90 | state: "last", 91 | storage: prevStorage, 92 | diff: {}, 93 | }, 94 | callbacks: { 95 | onFail: this._onFail !== undefined, 96 | onSuccess: this._onSuccess !== undefined, 97 | }, 98 | }, 99 | }; 100 | } finally { 101 | this._exit(); 102 | } 103 | } else { 104 | Web3Function._log(`Unrecognized parent process event: ${event.action}`); 105 | throw new Error(`Unrecognized parent process event: ${event.action}`); 106 | } 107 | } 108 | 109 | private async _invokeOnRun(ctxData: Web3FunctionOnRunContextData) { 110 | const context = this._context(ctxData); 111 | 112 | if (!this._onRun) 113 | throw new Error("Web3Function.onRun function is not registered"); 114 | 115 | const result = ctxData.log 116 | ? await (this._onRun as EventRunHandler)({ 117 | ...context, 118 | log: ctxData.log, 119 | }) 120 | : await (this._onRun as BaseRunHandler)(context); 121 | 122 | return { result, ctxData }; 123 | } 124 | 125 | private async _invokeOnFail(ctxData: Web3FunctionOnFailContextData) { 126 | const context = this._context(ctxData); 127 | 128 | if (!this._onFail) 129 | throw new Error("Web3Function.onFail function is not registered"); 130 | 131 | if (ctxData.onFailReason === "SimulationFailed") { 132 | await this._onFail({ 133 | ...context, 134 | reason: ctxData.onFailReason, 135 | callData: ctxData.callData as Web3FunctionResultCallData[], 136 | }); 137 | } else if (ctxData.onFailReason === "ExecutionReverted") { 138 | await this._onFail({ 139 | ...context, 140 | reason: ctxData.onFailReason, 141 | transactionHash: ctxData.transactionHash as string, 142 | }); 143 | } else if (ctxData.onFailReason === "InsufficientFunds") { 144 | await this._onFail({ 145 | ...context, 146 | reason: ctxData.onFailReason, 147 | }); 148 | } 149 | 150 | return { 151 | result: undefined, 152 | ctxData, 153 | }; 154 | } 155 | 156 | private async _invokeOnSuccess(ctxData: Web3FunctionOnSuccessContextData) { 157 | const context = this._context(ctxData); 158 | 159 | if (!this._onSuccess) 160 | throw new Error("Web3Function.onSuccess function is not registered"); 161 | 162 | await this._onSuccess({ 163 | ...context, 164 | transactionHash: ctxData.transactionHash, 165 | }); 166 | 167 | return { 168 | result: undefined, 169 | ctxData, 170 | }; 171 | } 172 | 173 | private _context(ctxData: Web3FunctionContextDataBase) { 174 | const context: Web3FunctionContext = { 175 | gelatoArgs: { 176 | ...ctxData.gelatoArgs, 177 | gasPrice: BigNumber.from(ctxData.gelatoArgs.gasPrice), 178 | }, 179 | multiChainProvider: this._initProvider( 180 | ctxData.rpcProviderUrl, 181 | ctxData.gelatoArgs.chainId 182 | ), 183 | userArgs: ctxData.userArgs, 184 | secrets: { 185 | get: async (key: string) => { 186 | Web3Function._log(`secrets.get(${key})`); 187 | return ctxData.secrets[key]; 188 | }, 189 | }, 190 | storage: { 191 | get: async (key: string) => { 192 | Web3Function._log(`storage.get(${key})`); 193 | return ctxData.storage[key]; 194 | }, 195 | set: async (key: string, value: string) => { 196 | if (typeof value !== "string") { 197 | throw new Error("Web3FunctionStorageError: value must be a string"); 198 | } 199 | Web3Function._log(`storage.set(${key},${value})`); 200 | ctxData.storage[key] = value; 201 | }, 202 | delete: async (key: string) => { 203 | Web3Function._log(`storage.delete(${key})`); 204 | ctxData.storage[key] = undefined; 205 | }, 206 | getKeys: async () => { 207 | Web3Function._log(`storage.getKeys()`); 208 | return Object.keys(ctxData.storage); 209 | }, 210 | getSize: async () => { 211 | Web3Function._log(`storage.getSize()`); 212 | var enc = new TextEncoder(); 213 | return enc.encode(JSON.stringify(ctxData.storage)).length; 214 | }, 215 | }, 216 | }; 217 | 218 | return context; 219 | } 220 | 221 | private _compareStorage( 222 | prevStorage: object, 223 | afterStorage: object 224 | ): { 225 | difference: object; 226 | state: "last" | "updated"; 227 | } { 228 | const difference = diff(prevStorage, afterStorage); 229 | for (const key in difference) { 230 | if (difference[key] === undefined) { 231 | difference[key] = null; 232 | } 233 | } 234 | 235 | const state = Object.keys(difference).length === 0 ? "last" : "updated"; 236 | 237 | return { difference, state }; 238 | } 239 | 240 | private _exit(code = 0, force = false) { 241 | if (force) { 242 | Deno.exit(code); 243 | } else { 244 | setTimeout(async () => { 245 | await this._server.waitConnectionReleased(); 246 | Deno.exit(code); 247 | }); 248 | } 249 | } 250 | 251 | static getInstance(): Web3Function { 252 | if (!Web3Function.Instance) { 253 | Web3Function.Instance = new Web3Function(); 254 | } 255 | return Web3Function.Instance; 256 | } 257 | 258 | static onRun(onRun: BaseRunHandler): void; 259 | static onRun(onRun: EventRunHandler): void; 260 | static onRun(onRun: any): void { 261 | Web3Function._log("Registering onRun function"); 262 | Web3Function.getInstance()._onRun = onRun; 263 | } 264 | 265 | static onSuccess(onSuccess: SuccessHandler): void; 266 | static onSuccess(onSuccess: any): void { 267 | Web3Function._log("Registering onSuccess function"); 268 | Web3Function.getInstance()._onSuccess = onSuccess; 269 | } 270 | 271 | static onFail(onFail: FailHandler): void; 272 | static onFail(onFail: any): void { 273 | Web3Function._log("Registering onFail function"); 274 | Web3Function.getInstance()._onFail = onFail; 275 | } 276 | 277 | static setDebug(debug: boolean) { 278 | Web3Function._debug = debug; 279 | } 280 | 281 | private static _log(message: string) { 282 | if (Web3Function._debug) console.log(`Web3Function: ${message}`); 283 | } 284 | 285 | private _onRpcRateLimit() { 286 | console.log("_onRpcRateLimit"); 287 | this._exit(250, true); 288 | } 289 | 290 | private _initProvider( 291 | providerUrl: string | undefined, 292 | defaultChainId: number 293 | ): Web3FunctionMultiChainProvider { 294 | if (!providerUrl) throw new Error("Missing providerUrl"); 295 | return new Web3FunctionMultiChainProvider( 296 | providerUrl, 297 | defaultChainId, 298 | this._onRpcRateLimit.bind(this) 299 | ); 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/lib/uploader/Web3FunctionUploader.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import "dotenv/config"; 3 | import FormData from "form-data"; 4 | import fs from "node:fs"; 5 | import fsp from "node:fs/promises"; 6 | import path from "node:path"; 7 | import tar from "tar"; 8 | import { Web3FunctionSchema } from "../types"; 9 | 10 | const OPS_USER_API = 11 | process.env.OPS_USER_API ?? "https://api.gelato.digital/automate/users"; 12 | 13 | const DOWNLOAD_MAX_SIZE = 1 * 1024 * 1024; // 1 MB; 14 | const EXTRACT_MAX_SIZE = 10 * 1024 * 1024; // 10 MB 15 | 16 | export class Web3FunctionUploader { 17 | public static async upload( 18 | schemaPath: string, 19 | filePath: string, 20 | sourcePath: string 21 | ): Promise { 22 | try { 23 | const compressedPath = await this.compress( 24 | filePath, 25 | schemaPath, 26 | sourcePath 27 | ); 28 | 29 | const cid = await this._userApiUpload(compressedPath); 30 | 31 | return cid; 32 | } catch (err) { 33 | throw new Error(`Web3FunctionUploaderError: ${err.message}`); 34 | } 35 | } 36 | 37 | public static async fetch( 38 | cid: string, 39 | destDir = path.join(process.cwd(), ".tmp") 40 | ): Promise { 41 | return new Promise((resolve, reject) => { 42 | // abort download when it exceeds the limit 43 | const downloadAbort = new AbortController(); 44 | const chunks: Buffer[] = []; 45 | 46 | axios 47 | .get(`${OPS_USER_API}/users/web3-function/${cid}`, { 48 | responseType: "stream", 49 | signal: downloadAbort.signal, 50 | }) 51 | .then((res) => { 52 | const web3FunctionFileName = `${cid}.tgz`; 53 | const web3FunctionPath = path.join(destDir, web3FunctionFileName); 54 | 55 | let downloadedSize = 0; 56 | res.data.on("data", (chunk) => { 57 | downloadedSize += chunk.length; 58 | 59 | if (downloadedSize >= DOWNLOAD_MAX_SIZE) { 60 | downloadAbort.abort(); 61 | } else { 62 | chunks.push(chunk); 63 | } 64 | }); 65 | 66 | res.data.on("end", async () => { 67 | const buffer = Buffer.concat(chunks); 68 | 69 | if (!fs.existsSync(destDir)) { 70 | fs.mkdirSync(destDir, { recursive: true }); 71 | } 72 | 73 | await fsp.writeFile(web3FunctionPath, buffer); 74 | resolve(web3FunctionPath); 75 | }); 76 | 77 | res.data.on("error", (err: Error) => { 78 | // handle download limit exceeding specifically 79 | if (axios.isCancel(err)) { 80 | reject( 81 | new Error( 82 | `file size is exceeding download limit ${DOWNLOAD_MAX_SIZE.toFixed( 83 | 2 84 | )}mb` 85 | ) 86 | ); 87 | } else { 88 | reject(err); 89 | } 90 | }); 91 | }) 92 | .catch((err) => { 93 | let errMsg = `${err.message} `; 94 | if (axios.isAxiosError(err)) { 95 | try { 96 | const data = JSON.parse(err.response?.data.toString("utf8")) as { 97 | message?: string; 98 | }; 99 | if (data.message) errMsg += data.message; 100 | } catch (err) { 101 | errMsg += err.message; 102 | } 103 | } 104 | 105 | reject( 106 | new Error( 107 | `Web3FunctionUploaderError: Fetch Web3Function ${cid} to ${destDir} failed. \n${errMsg}` 108 | ) 109 | ); 110 | }); 111 | }); 112 | } 113 | 114 | public static async compress( 115 | web3FunctionBuildPath: string, 116 | schemaPath: string, 117 | sourcePath: string 118 | ): Promise { 119 | try { 120 | await fsp.access(web3FunctionBuildPath); 121 | } catch (err) { 122 | throw new Error( 123 | `Web3Function build file not found at path. ${web3FunctionBuildPath} \n${err.message}` 124 | ); 125 | } 126 | 127 | // create directory with index.js, source.js & schema.json 128 | const folderCompressedName = `web3Function`; 129 | const folderCompressedPath = path.join( 130 | process.cwd(), 131 | ".tmp", 132 | folderCompressedName 133 | ); 134 | const folderCompressedTar = `${folderCompressedPath}.tgz`; 135 | 136 | if (!fs.existsSync(folderCompressedPath)) { 137 | fs.mkdirSync(folderCompressedPath, { recursive: true }); 138 | } 139 | 140 | // move files to directory 141 | await fsp.rename( 142 | web3FunctionBuildPath, 143 | path.join(folderCompressedPath, "index.js") 144 | ); 145 | await fsp.rename(sourcePath, path.join(folderCompressedPath, "source.js")); 146 | try { 147 | await fsp.copyFile( 148 | schemaPath, 149 | path.join(folderCompressedPath, "schema.json") 150 | ); 151 | } catch (err) { 152 | throw new Error( 153 | `Schema not found at path: ${schemaPath}. \n${err.message}` 154 | ); 155 | } 156 | 157 | const stream = tar 158 | .c( 159 | { 160 | gzip: true, 161 | cwd: path.join(process.cwd(), ".tmp"), 162 | noMtime: true, 163 | portable: true, 164 | }, 165 | [folderCompressedName] 166 | ) 167 | .pipe(fs.createWriteStream(folderCompressedTar)); 168 | 169 | await new Promise((fulfill) => { 170 | stream.once("finish", fulfill); 171 | }); 172 | 173 | // delete directory after compression 174 | await fsp.rm(folderCompressedPath, { recursive: true }); 175 | 176 | return folderCompressedTar; 177 | } 178 | 179 | public static async extract(input: string): Promise<{ 180 | dir: string; 181 | schemaPath: string; 182 | sourcePath: string; 183 | web3FunctionPath: string; 184 | }> { 185 | const tarExpectedFileNames = ["schema.json", "index.js", "source.js"]; 186 | 187 | try { 188 | const { dir, name } = path.parse(input); 189 | 190 | // rename directory to ipfs cid of web3Function if possible. 191 | const cidDirectory = path.join(dir, name); 192 | if (!fs.existsSync(cidDirectory)) { 193 | fs.mkdirSync(cidDirectory, { recursive: true }); 194 | } 195 | 196 | let extractedSize = 0; 197 | 198 | await tar.x({ 199 | file: input, 200 | cwd: cidDirectory, 201 | filter: (_, entry) => { 202 | extractedSize += entry.size; 203 | 204 | if (extractedSize >= EXTRACT_MAX_SIZE) { 205 | throw new Error( 206 | `extracted size exceeds max size ${EXTRACT_MAX_SIZE.toFixed(2)}mb` 207 | ); 208 | } 209 | 210 | const fileName = entry.path.split("/").pop(); 211 | if ( 212 | entry.type !== "File" || 213 | !tarExpectedFileNames.includes(fileName) 214 | ) { 215 | // Ignore unexpected files from archive 216 | return false; 217 | } 218 | 219 | return true; 220 | }, 221 | }); 222 | 223 | // remove tar file 224 | fs.rmSync(input, { recursive: true }); 225 | 226 | // move web3Function & schema to root ipfs cid directory 227 | fs.renameSync( 228 | path.join(cidDirectory, "web3Function", "schema.json"), 229 | path.join(cidDirectory, "schema.json") 230 | ); 231 | fs.renameSync( 232 | path.join(cidDirectory, "web3Function", "index.js"), 233 | path.join(cidDirectory, "index.js") 234 | ); 235 | fs.renameSync( 236 | path.join(cidDirectory, "web3Function", "source.js"), 237 | path.join(cidDirectory, "source.js") 238 | ); 239 | 240 | // remove web3Function directory 241 | fs.rmSync(path.join(cidDirectory, "web3Function"), { 242 | recursive: true, 243 | }); 244 | 245 | return { 246 | dir: cidDirectory, 247 | schemaPath: path.join(cidDirectory, "schema.json"), 248 | sourcePath: path.join(cidDirectory, "source.js"), 249 | web3FunctionPath: path.join(cidDirectory, "index.js"), 250 | }; 251 | } catch (err) { 252 | throw new Error( 253 | `Web3FunctionUploaderError: Extract Web3Function from ${input} failed. \n${err.message}` 254 | ); 255 | } 256 | } 257 | 258 | public static async fetchSchema(cid: string): Promise { 259 | try { 260 | const web3FunctionPath = await Web3FunctionUploader.fetch(cid); 261 | 262 | const { dir, schemaPath } = await Web3FunctionUploader.extract( 263 | web3FunctionPath 264 | ); 265 | 266 | const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")); 267 | 268 | fs.rmSync(dir, { recursive: true }); 269 | 270 | return schema; 271 | } catch (err) { 272 | throw new Error( 273 | `Web3FunctionUploaderError: Get schema of ${cid} failed: \n${err.message}` 274 | ); 275 | } 276 | } 277 | 278 | private static async _userApiUpload(compressedPath: string): Promise { 279 | try { 280 | const form = new FormData(); 281 | const file = fs.createReadStream(compressedPath); 282 | 283 | form.append("title", "Web3Function"); 284 | form.append("file", file); 285 | 286 | const res = await axios.post( 287 | `${OPS_USER_API}/users/web3-function`, 288 | form, 289 | { 290 | ...form.getHeaders(), 291 | } 292 | ); 293 | 294 | const cid = res.data.cid; 295 | 296 | // rename file with cid 297 | const { dir, ext } = path.parse(compressedPath); 298 | await fsp.rename(compressedPath, path.join(dir, `${cid}${ext}`)); 299 | 300 | return cid; 301 | } catch (err) { 302 | let errMsg = `${err.message} `; 303 | if (axios.isAxiosError(err)) { 304 | const data = err?.response?.data as { message?: string }; 305 | if (data.message) errMsg += data.message; 306 | } 307 | 308 | throw new Error(`Upload to User api failed. \n${errMsg}`); 309 | } 310 | } 311 | } 312 | --------------------------------------------------------------------------------