├── .changeset ├── README.md └── config.json ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS └── workflows │ ├── cd.yml │ ├── lint.yaml │ ├── sonar-scan.yaml │ ├── test-package.yaml │ └── test.yaml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── jest.config.js ├── jest.config.package.js ├── package-lock.json ├── package.json ├── sonar-project.properties ├── src ├── ResponseListener.ts ├── SecretsManager.ts ├── SubscriptionManager.ts ├── buildRequestCBOR.ts ├── decodeResult.ts ├── fetchRequestCommitment.ts ├── index.ts ├── localFunctionsTestnet.ts ├── offchain_storage │ ├── github.ts │ └── index.ts ├── simulateScript │ ├── Functions.ts │ ├── deno-sandbox │ │ └── sandbox.ts │ ├── frontendAllowedModules.ts │ ├── frontendSimulateScript.ts │ ├── index.ts │ ├── safePow.ts │ └── simulateScript.ts ├── simulationConfig.ts ├── tdh2.js ├── types.ts └── v1_contract_sources │ ├── FunctionsCoordinator.ts │ ├── FunctionsCoordinatorTestHelper.ts │ ├── FunctionsRouter.ts │ ├── LinkToken.ts │ ├── MockV3Aggregator.ts │ ├── TermsOfServiceAllowList.ts │ └── index.ts ├── test ├── integration │ ├── ResponseListener.test.ts │ ├── apiFixture.ts │ ├── fetchRequestCommitment.test.ts │ ├── integration.test.ts │ └── localFunctionsTestnet.test.ts ├── unit │ ├── Functions.test.ts │ ├── apiFixture.ts │ ├── buildRequestCBOR.test.ts │ ├── decode_result.test.ts │ ├── frontendAllowedModules.test.ts │ ├── frontendSimulateScript.test.ts │ ├── offchain_storage.test.ts │ ├── safePower.test.ts │ └── simulateScript.test.ts └── utils │ ├── contracts │ ├── FunctionsConsumer.sol │ └── FunctionsConsumerSource.ts │ ├── index.ts │ └── testSimulationConfig.ts ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "smartcontractkit/functions-toolkit" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "snapshot": { 15 | "useCalculatedVersion": true, 16 | "prereleaseTemplate": "{commit}" 17 | }, 18 | "updateInternalDependencies": "patch", 19 | "ignore": [] 20 | } 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | **/generated/** 3 | **/dist/** 4 | **/artifacts/** 5 | **/public/** 6 | **/build/** 7 | **/fixtures/** 8 | **/lib/** 9 | **/schema/** 10 | 11 | *.config.js 12 | *.config.ts 13 | *.config.package.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:prettier/recommended', 10 | ], 11 | overrides: [ 12 | { 13 | env: { 14 | node: true, 15 | }, 16 | files: ['.eslintrc.{js,cjs}'], 17 | parserOptions: { 18 | sourceType: 'script', 19 | }, 20 | }, 21 | ], 22 | parser: '@typescript-eslint/parser', 23 | parserOptions: { 24 | ecmaVersion: 'latest', 25 | sourceType: 'module', 26 | }, 27 | plugins: ['@typescript-eslint', 'prettier'], 28 | rules: { 29 | '@typescript-eslint/ban-ts-comment': 'off', 30 | 'linebreak-style': ['error', 'unix'], 31 | quotes: ['error', 'single', { avoidEscape: true }], 32 | semi: ['error', 'never'], 33 | 'prettier/prettier': 'error', 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @KuphJr @zeuslawyer -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | # This job will either: 10 | # 1. Upsert a "version" pull request if there are changesets and publish a dev snapshot to NPM 11 | # 2. Publish an official version to NPM if there are no changesets 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | environment: production 16 | permissions: 17 | id-token: write 18 | contents: read 19 | steps: 20 | - name: Assume role capable of getting token from gati 21 | uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 22 | with: 23 | role-to-assume: ${{ secrets.AWS_OIDC_FUNCTIONS_TOOLKIT_CI_CHANGESET_TOKEN_ISSUER_ROLE_ARN }} 24 | role-duration-seconds: '900' 25 | aws-region: ${{ secrets.AWS_REGION }} 26 | mask-aws-account-id: true 27 | 28 | - name: Get github token from gati 29 | id: gati 30 | uses: smartcontractkit/chainlink-github-actions/github-app-token-issuer@fc3e0df622521019f50d772726d6bf8dc919dd38 # v2.3.19 31 | with: 32 | url: ${{ secrets.LAMBDA_FUNCTIONS_URL }} 33 | 34 | - name: Checkout the repo 35 | uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 36 | with: 37 | # This sets up the local git config so that the changesets action 38 | # can commit changes to the repo on behalf of the GitHub Actions bot. 39 | token: ${{ steps.gati.outputs.access-token }} 40 | 41 | - name: Setup node 42 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 43 | with: 44 | cache: npm 45 | node-version: '18' 46 | # When registry-url is specified, a .npmrc file referencing NODE_AUTH_TOKEN 47 | # is created. This is needed for the changesets action to publish to NPM. 48 | registry-url: 'https://registry.npmjs.org' 49 | 50 | - name: Run npm ci 51 | run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional 52 | 53 | - name: Setup project 54 | run: npm run build 55 | 56 | - name: Create Release Pull Request or Publish to NPM 57 | id: changesets 58 | uses: smartcontractkit/.github/actions/signed-commits@ff80d56f5301dc8a65f66c4d47d746ee956beed9 # changesets-signed-commits@1.2.3 59 | with: 60 | publish: npx changeset publish 61 | env: 62 | GITHUB_TOKEN: ${{ steps.gati.outputs.access-token }} 63 | # Action needs NPM_TOKEN https://github.com/changesets/action#with-publishing 64 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 65 | # actions/setup-node creates an .npmrc file that references NODE_AUTH_TOKEN 66 | # https://github.com/actions/setup-node/blob/5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d/docs/advanced-usage.md?plain=1#L346 67 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | 69 | # The step above only calls the publish script if 70 | # there are no changesets to publish. When there are no changesets 71 | # to publish, it means that "changesets version" was run, consuming 72 | # the changesets. 73 | # 74 | # If there are changesets to publish, then the publish script is not 75 | # called, and the changesets are not consumed. 76 | # This means that we're in a "snapshot" state, and we should publish 77 | # a snapshot version for previewing. 78 | - name: Publish dev snapshot 79 | if: steps.changesets.outputs.published != 'true' 80 | env: 81 | GITHUB_TOKEN: ${{ steps.gati.outputs.access-token }} 82 | # actions/setup-node creates an .npmrc file that references NODE_AUTH_TOKEN 83 | # https://github.com/actions/setup-node/blob/5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d/docs/advanced-usage.md?plain=1#L346 84 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 85 | run: | 86 | # We need to checkout main again because the changesets action 87 | # consumes the changesets via "changeset version", but we 88 | # want to do a snapshot versioning instead, hence checking out 89 | # main again. 90 | git checkout main 91 | npx changeset version --snapshot 92 | npx changeset publish --tag dev 93 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint Check 2 | on: 3 | pull_request: 4 | jobs: 5 | lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout repository 9 | uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 10 | 11 | - name: Set up Node.js 12 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 13 | with: 14 | node-version: 18 15 | 16 | - name: Install dependencies 17 | run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional 18 | 19 | - name: Run lint check 20 | run: npm run lint:ci 21 | 22 | - name: Upload ESLint report 23 | if: always() 24 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 25 | with: 26 | name: eslint-report 27 | path: ./eslint-report.json 28 | -------------------------------------------------------------------------------- /.github/workflows/sonar-scan.yaml: -------------------------------------------------------------------------------- 1 | name: SonarQube Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | wait_for_workflows: 11 | name: Wait for workflows 12 | runs-on: ubuntu-latest 13 | if: always() 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 17 | with: 18 | ref: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} 19 | 20 | - name: Wait for workflows 21 | uses: smartcontractkit/chainlink-github-actions/utils/wait-for-workflows@fc3e0df622521019f50d772726d6bf8dc919dd38 # v2.3.19 22 | with: 23 | max-timeout: '900' 24 | polling-interval: '30' 25 | exclude-workflow-names: 'Package Artifact Tests' 26 | exclude-workflow-ids: '' 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | env: 29 | DEBUG: 'true' 30 | 31 | sonarqube: 32 | name: SonarQube Scan 33 | needs: [wait_for_workflows] 34 | runs-on: ubuntu-latest 35 | if: always() 36 | steps: 37 | - name: Checkout the repo 38 | uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 39 | with: 40 | fetch-depth: 0 # fetches all history for all tags and branches to provide more metadata for sonar reports 41 | 42 | - name: Download ESLint report 43 | uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 44 | with: 45 | workflow: lint.yaml 46 | workflow_conclusion: '' 47 | name: eslint-report 48 | if_no_artifact_found: warn 49 | 50 | - name: Download tests report 51 | uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6 52 | with: 53 | workflow: test.yaml 54 | workflow_conclusion: '' 55 | name: unit-tests-coverage 56 | if_no_artifact_found: warn 57 | 58 | - name: Set SonarQube Report Paths 59 | if: always() 60 | id: sonarqube_report_paths 61 | shell: bash 62 | run: | 63 | echo "sonarqube_coverage_report_paths=$(find -type f -name 'lcov.info' -printf "%p,")" >> $GITHUB_OUTPUT 64 | echo "sonarqube_eslint_report_paths=$(find -type f -name 'eslint-report.json' -printf "%p")" >> $GITHUB_OUTPUT 65 | - name: Update ESLint report symlinks 66 | continue-on-error: true 67 | run: sed -i 's+/home/runner/work/functions-toolkit/functions-toolkit/+/github/workspace/+g' ${{ steps.sonarqube_report_paths.outputs.sonarqube_eslint_report_paths }} 68 | 69 | - name: SonarQube Scan 70 | if: always() 71 | uses: sonarsource/sonarqube-scan-action@86fe81775628f1c6349c28baab87881a2170f495 # v2.1.0 72 | with: 73 | args: > 74 | -Dsonar.javascript.lcov.reportPaths=${{ steps.sonarqube_report_paths.outputs.sonarqube_coverage_report_paths }} 75 | -Dsonar.eslint.reportPaths=${{ steps.sonarqube_report_paths.outputs.sonarqube_eslint_report_paths }} 76 | env: 77 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 78 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} 79 | -------------------------------------------------------------------------------- /.github/workflows/test-package.yaml: -------------------------------------------------------------------------------- 1 | name: Package Artifact Tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test-package-artifacts: 8 | runs-on: ubuntu-latest 9 | env: 10 | DEBUG_TEST_SETUP: true 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 18 | with: 19 | node-version: 18 20 | 21 | - name: setup-foundry 22 | uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # v1.3.1 23 | 24 | - name: Install dependencies 25 | run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional 26 | 27 | - name: Setup Deno 28 | uses: denoland/setup-deno@041b854f97b325bd60e53e9dc2de9cb9f9ac0cba # v1.1.4 29 | with: 30 | deno-version: '1.36.2' 31 | 32 | - name: Build package artifacts 33 | run: npm run build 34 | 35 | - name: Remove source files 36 | run: rm -rf src 37 | 38 | - name: Run tests against package artifacts 39 | run: npm run test:package 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | env: 13 | DEBUG_TEST_SETUP: true 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 21 | with: 22 | node-version: 18 23 | 24 | - name: setup-foundry 25 | uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # v1.3.1 26 | 27 | - name: Install dependencies 28 | run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency 29 | 30 | - name: Setup Deno 31 | uses: denoland/setup-deno@041b854f97b325bd60e53e9dc2de9cb9f9ac0cba # v1.1.4 32 | with: 33 | deno-version: '1.36.2' 34 | 35 | # 'integration' are the Jest tests that cover code in unit-testing way, so both are included 36 | - name: Run unit tests 37 | run: npm run test:ci 38 | 39 | - name: Upload test coverage report 40 | if: always() 41 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 42 | with: 43 | name: unit-tests-coverage 44 | path: ./coverage/lcov.info 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local dev modules 2 | node_modules 3 | .DS_Store 4 | .vscode/ 5 | dist 6 | 7 | # Reports 8 | /coverage 9 | coverage.json 10 | *report.json 11 | 12 | *.env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *report.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @chainlink/functions-toolkit 2 | 3 | ## 0.3.2 4 | 5 | ### Patch Changes 6 | 7 | - [#69](https://github.com/smartcontractkit/functions-toolkit/pull/69) [`b3da1b3`](https://github.com/smartcontractkit/functions-toolkit/commit/b3da1b34b2ab94187fbc5885e1848d3eee13dfac) Thanks [@ernest-nowacki](https://github.com/ernest-nowacki)! - Improve the error message around remove consumer input validation 8 | 9 | ## 0.3.1 10 | 11 | ### Patch Changes 12 | 13 | - [#67](https://github.com/smartcontractkit/functions-toolkit/pull/67) [`073c381`](https://github.com/smartcontractkit/functions-toolkit/commit/073c381e4ba5854f4a5eebddcf152515c4933772) Thanks [@zeuslawyer](https://github.com/zeuslawyer)! - Change default minimum confirmations from listenForResponseFromTransaction() to 1. 14 | 15 | ## 0.3.0 16 | 17 | ### Minor Changes 18 | 19 | - [#66](https://github.com/smartcontractkit/functions-toolkit/pull/66) [`a6d789e`](https://github.com/smartcontractkit/functions-toolkit/commit/a6d789e2881bef8341c621b11ef99a0c81b5261e) Thanks [@KuphJr](https://github.com/KuphJr)! - Enable timing out requests on Coordinator v1.3.1 20 | 21 | - [#57](https://github.com/smartcontractkit/functions-toolkit/pull/57) [`595bf30`](https://github.com/smartcontractkit/functions-toolkit/commit/595bf30fddcd24620a1a991e4c92a742fc0e9c4f) Thanks [@justinkaseman](https://github.com/justinkaseman)! - Update to Functions Contracts v1.3.0 22 | 23 | ## 0.2.8 24 | 25 | ### Patch Changes 26 | 27 | - [#48](https://github.com/smartcontractkit/functions-toolkit/pull/48) [`a27e840`](https://github.com/smartcontractkit/functions-toolkit/commit/a27e840e71dfee14b0e083f45cd46c1578fbb511) Thanks [@KuphJr](https://github.com/KuphJr)! - Update frontend simulator 28 | 29 | ## 0.2.7 30 | 31 | ### Patch Changes 32 | 33 | - [#46](https://github.com/smartcontractkit/functions-toolkit/pull/46) [`d570bf8`](https://github.com/smartcontractkit/functions-toolkit/commit/d570bf8363a08056b442dc7a0437449152aa7dda) Thanks [@KuphJr](https://github.com/KuphJr)! - Added support for 3rd party imports in the simulator 34 | 35 | ## 0.2.6 36 | 37 | ### Patch Changes 38 | 39 | - [#36](https://github.com/smartcontractkit/functions-toolkit/pull/36) [`6026203`](https://github.com/smartcontractkit/functions-toolkit/commit/6026203593e6cf7239e51b6b2f14df4bfdc3a0f2) Thanks [@bolekk](https://github.com/bolekk)! - Added ResponseListener.listenForResponseFromTransaction() method to handle listening for responses if the request was reorged and the requestId changed 40 | 41 | ## 0.2.5 42 | 43 | ### Patch Changes 44 | 45 | - [#30](https://github.com/smartcontractkit/functions-toolkit/pull/30) [`c1f6d2d`](https://github.com/smartcontractkit/functions-toolkit/commit/c1f6d2d7b2c7d8d879b94962ee521381d1f99ac8) Thanks [@KuphJr](https://github.com/KuphJr)! - README improvements 46 | 47 | - [#28](https://github.com/smartcontractkit/functions-toolkit/pull/28) [`646749b`](https://github.com/smartcontractkit/functions-toolkit/commit/646749bcabc8b9a971187d359db48e3b1a38ba18) Thanks [@KuphJr](https://github.com/KuphJr)! - Improve log msg for timeouts 48 | 49 | ## 0.2.4 50 | 51 | ### Patch Changes 52 | 53 | - [#26](https://github.com/smartcontractkit/functions-toolkit/pull/26) [`daac368`](https://github.com/smartcontractkit/functions-toolkit/commit/daac368e8d05c574262cb6f946c77f629cd216ea) Thanks [@KuphJr](https://github.com/KuphJr)! - Added commitment fetching 54 | 55 | ## 0.2.3 56 | 57 | ### Patch Changes 58 | 59 | - [#19](https://github.com/smartcontractkit/functions-toolkit/pull/19) [`86b822c`](https://github.com/smartcontractkit/functions-toolkit/commit/86b822c16f3f93ea4916f36e6067ce060500ae6c) Thanks [@KuphJr](https://github.com/KuphJr)! - Support `0x` in decodeResult, fixed simulator shutdown when error is encountered, added explicitly supported versions to README 60 | 61 | ## 0.2.2 62 | 63 | ### Patch Changes 64 | 65 | - [#17](https://github.com/smartcontractkit/functions-toolkit/pull/17) [`6e98aa6`](https://github.com/smartcontractkit/functions-toolkit/commit/6e98aa638bb1b6ee11a787e2dc15ca5739d02bb8) Thanks [@KuphJr](https://github.com/KuphJr)! - Removed maximum expiration check for DON hosted secrets 66 | 67 | ## 0.2.1 68 | 69 | ### Patch Changes 70 | 71 | - [#11](https://github.com/smartcontractkit/functions-toolkit/pull/11) [`bdc680e`](https://github.com/smartcontractkit/functions-toolkit/commit/bdc680e9b112cf6fc5397a9b062d4578e2c0db49) Thanks [@KuphJr](https://github.com/KuphJr)! - Added maximum expiration for DON hosted secrets 72 | 73 | ## 0.2.0 74 | 75 | ### Minor Changes 76 | 77 | - [#9](https://github.com/smartcontractkit/functions-toolkit/pull/9) [`7ad428a`](https://github.com/smartcontractkit/functions-toolkit/commit/7ad428a5cd49651642bfa33dc6896011e687bae2) Thanks [@KuphJr](https://github.com/KuphJr)! - Changed DON ID for localFunctionsTestnet from coordinator1 to local-functions-testnet 78 | 79 | ### Patch Changes 80 | 81 | - [#9](https://github.com/smartcontractkit/functions-toolkit/pull/9) [`407b39f`](https://github.com/smartcontractkit/functions-toolkit/commit/407b39f4eeeff300f28a5e85bf550de9351f52af) Thanks [@KuphJr](https://github.com/KuphJr)! - Fixed CBOR parsing & error handling in localFunctionsTestnet 82 | 83 | ## 0.1.0 84 | 85 | ### Minor Changes 86 | 87 | - [#8](https://github.com/smartcontractkit/functions-toolkit/pull/8) [`451710d`](https://github.com/smartcontractkit/functions-toolkit/commit/451710d6d80a70218f0f7e793a2677f6815b7139) Thanks [@KuphJr](https://github.com/KuphJr)! - Updated to latest Functions contracts 88 | 89 | - [#4](https://github.com/smartcontractkit/functions-toolkit/pull/4) [`e31d1c6`](https://github.com/smartcontractkit/functions-toolkit/commit/e31d1c6e82d7ff0e7128aea0dc024e572c7a6050) Thanks [@KuphJr](https://github.com/KuphJr)! - Added localFunctionsTestnet 90 | 91 | ### Patch Changes 92 | 93 | - [#3](https://github.com/smartcontractkit/functions-toolkit/pull/3) [`40d6a83`](https://github.com/smartcontractkit/functions-toolkit/commit/40d6a831ee7726d25c43d8041ff6f33ed3c385b9) Thanks [@KuphJr](https://github.com/KuphJr)! - Renamed storageSlotId to slotId for SecretsManager.uploadEncryptedSecretsToDON() 94 | 95 | ## 0.0.3 96 | 97 | ### Patch Changes 98 | 99 | - [#1](https://github.com/smartcontractkit/functions-toolkit/pull/1) [`2dc777d`](https://github.com/smartcontractkit/functions-toolkit/commit/2dc777de7316974405e5bf669ae4bbacbe5e09a5) Thanks [@KuphJr](https://github.com/KuphJr)! - Test CI workflow 100 | 101 | ## 0.0.2 102 | 103 | ### Patch Changes 104 | 105 | - [#71](https://github.com/smartcontractkit/functions-toolkit/pull/71) [`610b3d0`](https://github.com/smartcontractkit/functions-toolkit/commit/610b3d035d6e0a64470b721b8f9e3a56814d7e3a) Thanks [@HenryNguyen5](https://github.com/HenryNguyen5)! - Remove defunct changelog for changeset managed one 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 SmartContract 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Define the default target 4 | .PHONY: all 5 | all: install 6 | 7 | # Install Foundry and npm packages 8 | .PHONY: install 9 | install: 10 | @echo "Installing Foundry..." 11 | curl -L https://foundry.paradigm.xyz | bash 12 | @echo "Installing npm packages..." 13 | npm install 14 | 15 | # Clean up the project 16 | .PHONY: clean 17 | clean: 18 | @echo "Cleaning up..." 19 | rm -rf node_modules 20 | rm -rf dist -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/test/**/*.test.ts'], 6 | testTimeout: 5 * 60 * 1000, 7 | 8 | coverageReporters: ['html', 'lcov'], 9 | collectCoverageFrom: ['src/**/*.ts', '!src/simulateScript/deno-sandbox/*.ts'], 10 | coverageThreshold: { 11 | global: { 12 | branches: 95, 13 | functions: 95, 14 | lines: 95, 15 | statements: 95, 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.package.js: -------------------------------------------------------------------------------- 1 | // Jest configuration for package tests 2 | // We map ../../src to ../../dist so that we can test the transpiled code 3 | // to ensure that consumers of this package will be able to use it as we expect 4 | /** @type {import('jest').Config} */ 5 | module.exports = { 6 | ...require('./jest.config.js'), 7 | moduleNameMapper: { 8 | // remap ../../src/xxx to ../../dist/xxx 9 | '^../../src$': '', 10 | '^../../src/(.*)$': '/dist/$1', 11 | }, 12 | 13 | // See https://github.com/kulshekhar/ts-jest/issues/822#issuecomment-1465241173 14 | // We disable type checking for package tests otherwise ts-jest will error out when checking 15 | // ../../src 16 | // This is fine because we have type checking in the main jest.config.js 17 | transform: { 18 | '^.+\\.tsx?$': [ 19 | 'ts-jest', 20 | { 21 | diagnostics: false, 22 | }, 23 | ], 24 | }, 25 | 26 | // turn off coverage for package tests 27 | collectCoverageFrom: undefined, 28 | coverageThreshold: undefined, 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@chainlink/functions-toolkit", 3 | "private": false, 4 | "version": "0.3.2", 5 | "description": "An NPM package with collection of functions that can be used for working with Chainlink Functions.", 6 | "main": "./dist/index.js", 7 | "scripts": { 8 | "build": "rimraf dist && tsc -P tsconfig.build.json && cpy src/simulateScript/deno-sandbox/**/* dist/simulateScript/deno-sandbox && yarn build:browser", 9 | "build:browser": "webpack && browserify dist/frontendSimulateScript.bundle.js -o dist/simulateScript.browser.js -t [ babelify --presets [ @babel/preset-env ] ] --standalone simulateScript && rm dist/frontendSimulateScript.bundle.js", 10 | "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest", 11 | "test:ci": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --silent --ci --coverage", 12 | "test:package": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --config jest.config.package.js", 13 | "lint:check": "eslint '{src,test}/**/*.ts' && tsc --noEmit", 14 | "lint:fix": "eslint '{src,test}/**/*.ts' --fix", 15 | "lint:ci": "eslint '{src,test}/**/*.ts' --max-warnings=0 -f json -o eslint-report.json" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/smartcontractkit/functions-toolkit.git" 20 | }, 21 | "keywords": [ 22 | "chainlink", 23 | "functions", 24 | "blockchain", 25 | "oracle" 26 | ], 27 | "author": "Morgan Kuphal (@KuphJr) & Zubin Pratap (@ZeusLawyer)", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/smartcontractkit/functions-toolkit/issues" 31 | }, 32 | "files": [ 33 | "dist" 34 | ], 35 | "homepage": "https://github.com/smartcontractkit/functions-toolkit#readme", 36 | "devDependencies": { 37 | "@babel/core": "^7.21.8", 38 | "@babel/preset-env": "^7.21.5", 39 | "@babel/preset-typescript": "7.21.5", 40 | "@changesets/changelog-github": "^0.4.8", 41 | "@changesets/cli": "^2.26.2", 42 | "@types/jest": "^29.5.1", 43 | "@types/node": "^18.16.3", 44 | "@types/prettier": "^2.7.3", 45 | "@typescript-eslint/eslint-plugin": "^6.7.5", 46 | "@typescript-eslint/parser": "^6.7.5", 47 | "babel-loader": "9.1.2", 48 | "babelify": "^10.0.0", 49 | "browserify": "17.0.0", 50 | "cpy-cli": "^5.0.0", 51 | "eslint": "^8.51.0", 52 | "eslint-config-prettier": "^9.0.0", 53 | "eslint-plugin-prettier": "^5.0.1", 54 | "jest": "^29.5.0", 55 | "nock": "^13.3.1", 56 | "prettier": "^3.0.3", 57 | "rimraf": "^5.0.1", 58 | "ts-jest": "^29.1.0", 59 | "typescript": "^5.0.4", 60 | "webpack": "^5.97.1", 61 | "webpack-cli": "5.1.1" 62 | }, 63 | "prettier": { 64 | "printWidth": 100, 65 | "tabWidth": 2, 66 | "useTabs": false, 67 | "semi": false, 68 | "singleQuote": true, 69 | "trailingComma": "all", 70 | "bracketSpacing": true, 71 | "arrowParens": "avoid", 72 | "proseWrap": "preserve" 73 | }, 74 | "dependencies": { 75 | "@viem/anvil": "^0.0.10", 76 | "axios": "^1.4.0", 77 | "bcrypto": "^5.4.0", 78 | "cbor": "^9.0.1", 79 | "eth-crypto": "^2.7.0", 80 | "ethers": "^5.7.2", 81 | "uniq": "^1.0.1" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=smartcontractkit_functions-toolkit 2 | sonar.sources=. 3 | 4 | # Full exclusions from the static analysis 5 | sonar.exclusions=**/node_modules/**/*, **/mocks/**/*, **/testdata/**/*, **/demo/**/*, **/contracts/**/*, **/generated/**/*, **/fixtures/**/*, **/docs/**/*, **/tools/**/*, **/*.fixtures.ts, **/apiFixture.ts, **/*report.xml, **/*.config.ts, **/*.config.js, **/*.config.package.js, **/*.txt, **/*.abi, **/*.bin 6 | # Coverage exclusions 7 | sonar.coverage.exclusions=**/*.test.ts, **/test/**/*, **/src/simulateScript/deno-sandbox/**/*, **/index.ts 8 | # Duplications exclusions 9 | sonar.cpd.exclusions=**/src/v1_contract_sources/**/* 10 | 11 | # Tests' root folder, inclusions (tests to check and count) and exclusions 12 | sonar.tests=. 13 | sonar.test.inclusions=**/*.test.ts -------------------------------------------------------------------------------- /src/ResponseListener.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from 'ethers' 2 | 3 | import { FunctionsRouterSource } from './v1_contract_sources' 4 | 5 | import type { BigNumber, providers } from 'ethers' 6 | 7 | import { FulfillmentCode, type FunctionsResponse } from './types' 8 | 9 | export class ResponseListener { 10 | private functionsRouter: Contract 11 | private provider: providers.Provider 12 | 13 | constructor({ 14 | provider, 15 | functionsRouterAddress, 16 | }: { 17 | provider: providers.Provider 18 | functionsRouterAddress: string 19 | }) { 20 | this.provider = provider 21 | this.functionsRouter = new Contract(functionsRouterAddress, FunctionsRouterSource.abi, provider) 22 | } 23 | 24 | public async listenForResponse( 25 | requestId: string, 26 | timeoutMs = 300000, 27 | ): Promise { 28 | let expirationTimeout: NodeJS.Timeout 29 | const responsePromise = new Promise((resolve, reject) => { 30 | expirationTimeout = setTimeout(() => { 31 | reject('Response not received within timeout period') 32 | }, timeoutMs) 33 | 34 | this.functionsRouter.on( 35 | 'RequestProcessed', 36 | ( 37 | _requestId: string, 38 | subscriptionId: BigNumber, 39 | totalCostJuels: BigNumber, 40 | _, 41 | resultCode: number, 42 | response: string, 43 | err: string, 44 | returnData: string, 45 | ) => { 46 | if (requestId === _requestId && resultCode !== FulfillmentCode.INVALID_REQUEST_ID) { 47 | clearTimeout(expirationTimeout) 48 | this.functionsRouter.removeAllListeners('RequestProcessed') 49 | resolve({ 50 | requestId, 51 | subscriptionId: Number(subscriptionId.toString()), 52 | totalCostInJuels: BigInt(totalCostJuels.toString()), 53 | responseBytesHexstring: response, 54 | errorString: Buffer.from(err.slice(2), 'hex').toString(), 55 | returnDataBytesHexstring: returnData, 56 | fulfillmentCode: resultCode, 57 | }) 58 | } 59 | }, 60 | ) 61 | }) 62 | 63 | return responsePromise 64 | } 65 | 66 | /** 67 | * 68 | * @param txHash Tx hash for the Functions Request 69 | * @param timeoutMs after which the listener throws, indicating the time limit was exceeded (default 5 minutes) 70 | * @param confirmations number of confirmations to wait for before considering the transaction successful (default 1, but recommend 2 or more) 71 | * @param checkIntervalMs frequency of checking if the Tx is included on-chain (or if it got moved after a chain re-org) (default 2 seconds. Intervals longer than block time may cause the listener to wait indefinitely.) 72 | * @returns 73 | */ 74 | public async listenForResponseFromTransaction( 75 | txHash: string, 76 | timeoutMs = 3000000, 77 | confirmations = 1, 78 | checkIntervalMs = 2000, 79 | ): Promise { 80 | return new Promise((resolve, reject) => { 81 | ;(async () => { 82 | let requestId: string 83 | // eslint-disable-next-line prefer-const 84 | let checkTimeout: NodeJS.Timeout 85 | const expirationTimeout = setTimeout(() => { 86 | reject('Response not received within timeout period') 87 | }, timeoutMs) 88 | 89 | const check = async () => { 90 | const receipt = await this.provider.waitForTransaction(txHash, confirmations, timeoutMs) 91 | const updatedId = receipt.logs[0].topics[1] 92 | if (updatedId !== requestId) { 93 | requestId = updatedId 94 | const response = await this.listenForResponse(requestId, timeoutMs) 95 | if (updatedId === requestId) { 96 | // Resolve only if the ID hasn't changed in the meantime 97 | clearTimeout(expirationTimeout) 98 | clearInterval(checkTimeout) 99 | resolve(response) 100 | } 101 | } 102 | } 103 | 104 | // Check periodically if the transaction has been re-orged and requestID changed 105 | checkTimeout = setInterval(check, checkIntervalMs) 106 | 107 | check() 108 | })() 109 | }) 110 | } 111 | 112 | public listenForResponses( 113 | subscriptionId: number | string, 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | callback: (functionsResponse: FunctionsResponse) => any, 116 | ) { 117 | if (typeof subscriptionId === 'string') { 118 | subscriptionId = Number(subscriptionId) 119 | } 120 | 121 | this.functionsRouter.on( 122 | 'RequestProcessed', 123 | ( 124 | requestId: string, 125 | _subscriptionId: BigNumber, 126 | totalCostJuels: BigNumber, 127 | _, 128 | resultCode: number, 129 | response: string, 130 | err: string, 131 | returnData: string, 132 | ) => { 133 | if ( 134 | subscriptionId === Number(_subscriptionId.toString()) && 135 | resultCode !== FulfillmentCode.INVALID_REQUEST_ID 136 | ) { 137 | this.functionsRouter.removeAllListeners('RequestProcessed') 138 | callback({ 139 | requestId, 140 | subscriptionId: Number(subscriptionId.toString()), 141 | totalCostInJuels: BigInt(totalCostJuels.toString()), 142 | responseBytesHexstring: response, 143 | errorString: Buffer.from(err.slice(2), 'hex').toString(), 144 | returnDataBytesHexstring: returnData, 145 | fulfillmentCode: resultCode, 146 | }) 147 | } 148 | }, 149 | ) 150 | } 151 | 152 | public stopListeningForResponses() { 153 | this.functionsRouter.removeAllListeners('RequestProcessed') 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/SecretsManager.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import cbor from 'cbor' 3 | import { Contract, utils } from 'ethers' 4 | import EthCrypto from 'eth-crypto' 5 | 6 | import { encrypt } from './tdh2.js' 7 | import { FunctionsRouterSource, FunctionsCoordinatorSource } from './v1_contract_sources' 8 | 9 | import type { AxiosResponse } from 'axios' 10 | import type { Signer } from 'ethers' 11 | 12 | import type { 13 | GatewayMessageBody, 14 | GatewayMessageConfig, 15 | ThresholdPublicKey, 16 | NodeResponse, 17 | GatewayResponse, 18 | } from './types' 19 | 20 | export class SecretsManager { 21 | private signer: Signer 22 | private functionsRouter: Contract 23 | private functionsCoordinator!: Contract 24 | private donId?: string 25 | private initialized = false 26 | 27 | constructor({ 28 | signer, 29 | functionsRouterAddress, 30 | donId, 31 | }: { 32 | signer: Signer 33 | functionsRouterAddress: string 34 | donId: string 35 | }) { 36 | this.signer = signer 37 | this.donId = donId 38 | 39 | if (!signer.provider) { 40 | throw Error('The signer used to instantiate the Secrets Manager must have a provider') 41 | } 42 | 43 | this.functionsRouter = new Contract(functionsRouterAddress, FunctionsRouterSource.abi, signer) 44 | } 45 | 46 | public async initialize(): Promise { 47 | const donIdBytes32 = utils.formatBytes32String(this.donId!) 48 | 49 | let functionsCoordinatorAddress: string 50 | try { 51 | functionsCoordinatorAddress = await this.functionsRouter.getContractById(donIdBytes32) 52 | } catch (error) { 53 | throw Error( 54 | `${error}\n\nError encountered when attempting to fetch the FunctionsCoordinator address.\nEnsure the FunctionsRouter address and donId are correct and that that the provider is able to connect to the blockchain.`, 55 | ) 56 | } 57 | 58 | this.functionsCoordinator = new Contract( 59 | functionsCoordinatorAddress, 60 | FunctionsCoordinatorSource.abi, 61 | this.signer, 62 | ) 63 | 64 | this.initialized = true 65 | } 66 | 67 | private isInitialized = (): void => { 68 | if (!this.initialized) { 69 | throw Error('SecretsManager has not been initialized. Call the initialize() method first.') 70 | } 71 | } 72 | 73 | /** 74 | * @returns a Promise that resolves to an object that contains the DONpublicKey and an object that maps node addresses to their public keys 75 | */ 76 | public async fetchKeys(): Promise<{ 77 | thresholdPublicKey: ThresholdPublicKey 78 | donPublicKey: string 79 | }> { 80 | this.isInitialized() 81 | 82 | const thresholdPublicKeyBytes = await this.functionsCoordinator.getThresholdPublicKey() 83 | const thresholdPublicKey: ThresholdPublicKey = JSON.parse( 84 | Buffer.from(thresholdPublicKeyBytes.slice(2), 'hex').toString('utf-8'), 85 | ) 86 | 87 | const donPublicKey = (await this.functionsCoordinator.getDONPublicKey()).slice(2) 88 | 89 | return { thresholdPublicKey, donPublicKey } 90 | } 91 | 92 | public async encryptSecretsUrls(secretsUrls: string[]): Promise { 93 | if (!Array.isArray(secretsUrls) || secretsUrls.length === 0) { 94 | throw Error('Must provide an array of secrets URLs') 95 | } 96 | if (!secretsUrls.every(url => typeof url === 'string')) { 97 | throw Error('All secrets URLs must be strings') 98 | } 99 | try { 100 | secretsUrls.forEach(url => new URL(url)) 101 | } catch (e) { 102 | throw Error(`Error encountered when attempting to validate a secrets URL: ${e}`) 103 | } 104 | const donPublicKey = (await this.fetchKeys()).donPublicKey 105 | const encrypted = await EthCrypto.encryptWithPublicKey(donPublicKey, secretsUrls.join(' ')) 106 | return '0x' + EthCrypto.cipher.stringify(encrypted) 107 | } 108 | 109 | public async verifyOffchainSecrets(secretsUrls: string[]): Promise { 110 | let lastFetchedEncryptedSecrets: string | undefined 111 | 112 | for (const url of secretsUrls) { 113 | let response 114 | try { 115 | response = await axios.get(url) 116 | } catch (e) { 117 | throw Error(`Error encountered when attempting to fetch URL ${url}: ${e}`) 118 | } 119 | 120 | if (!response.data?.encryptedSecrets) { 121 | throw Error(`URL ${url} did not return a JSON object with an encryptedSecrets field`) 122 | } 123 | 124 | if (!utils.isHexString(response.data.encryptedSecrets)) { 125 | throw Error(`URL ${url} did not return a valid hex string for the encryptedSecrets field`) 126 | } 127 | 128 | if ( 129 | lastFetchedEncryptedSecrets && 130 | lastFetchedEncryptedSecrets !== response.data.encryptedSecrets 131 | ) { 132 | throw Error(`URL ${url} returned a different encryptedSecrets field than the previous URL`) 133 | } 134 | 135 | lastFetchedEncryptedSecrets = response.data.encryptedSecrets 136 | } 137 | 138 | return true 139 | } 140 | 141 | public async encryptSecrets( 142 | secrets?: Record, 143 | ): Promise<{ encryptedSecrets: string }> { 144 | if (!secrets || Object.keys(secrets).length === 0) { 145 | throw Error('Secrets are empty') 146 | } 147 | 148 | if ( 149 | typeof secrets !== 'object' || 150 | !Object.values(secrets).every(s => { 151 | return typeof s === 'string' 152 | }) 153 | ) { 154 | throw Error('Secrets are not a string map') 155 | } 156 | 157 | const { thresholdPublicKey, donPublicKey } = await this.fetchKeys() 158 | 159 | const message = JSON.stringify(secrets) 160 | const signature = await this.signer.signMessage(message) 161 | 162 | const signedSecrets = JSON.stringify({ 163 | message, 164 | signature, 165 | }) 166 | 167 | const encryptedSignedSecrets = EthCrypto.cipher.stringify( 168 | await EthCrypto.encryptWithPublicKey(donPublicKey, signedSecrets), 169 | ) 170 | 171 | const donKeyEncryptedSecrets = { 172 | '0x0': Buffer.from(encryptedSignedSecrets, 'hex').toString('base64'), 173 | } 174 | 175 | const encryptedSecretsHexstring = 176 | '0x' + 177 | Buffer.from( 178 | encrypt(thresholdPublicKey, Buffer.from(JSON.stringify(donKeyEncryptedSecrets))), 179 | ).toString('hex') 180 | 181 | return { 182 | encryptedSecrets: encryptedSecretsHexstring, 183 | } 184 | } 185 | 186 | public async uploadEncryptedSecretsToDON({ 187 | encryptedSecretsHexstring, 188 | gatewayUrls, 189 | slotId, 190 | minutesUntilExpiration, 191 | }: { 192 | encryptedSecretsHexstring: string 193 | gatewayUrls: string[] 194 | slotId: number 195 | minutesUntilExpiration: number 196 | }): Promise<{ version: number; success: boolean }> { 197 | this.isInitialized() 198 | this.validateGatewayUrls(gatewayUrls) 199 | 200 | if (!utils.isHexString(encryptedSecretsHexstring)) { 201 | throw Error('encryptedSecretsHexstring must be a valid hex string') 202 | } 203 | 204 | if (!Number.isInteger(slotId) || slotId < 0) { 205 | throw Error('slotId must be a integer of at least 0') 206 | } 207 | 208 | if (!Number.isInteger(minutesUntilExpiration) || minutesUntilExpiration < 5) { 209 | throw Error('minutesUntilExpiration must be an integer of at least 5') 210 | } 211 | 212 | const encryptedSecretsBase64 = Buffer.from(encryptedSecretsHexstring.slice(2), 'hex').toString( 213 | 'base64', 214 | ) 215 | const signerAddress = await this.signer.getAddress() 216 | const signerAddressBase64 = Buffer.from(signerAddress.slice(2), 'hex').toString('base64') 217 | const secretsVersion = Math.floor(Date.now() / 1000) 218 | const secretsExpiration = Date.now() + minutesUntilExpiration * 60 * 1000 219 | 220 | const message = { 221 | address: signerAddressBase64, 222 | slotid: slotId, 223 | payload: encryptedSecretsBase64, 224 | version: secretsVersion, 225 | expiration: secretsExpiration, 226 | } 227 | const storageSignature = await this.signer.signMessage(JSON.stringify(message)) 228 | const storageSignatureBase64 = Buffer.from(storageSignature.slice(2), 'hex').toString('base64') 229 | 230 | const payload = { 231 | slot_id: slotId, 232 | version: secretsVersion, 233 | payload: encryptedSecretsBase64, 234 | expiration: secretsExpiration, 235 | signature: storageSignatureBase64, 236 | } 237 | 238 | const gatewayResponse = await this.sendMessageToGateways({ 239 | gatewayUrls, 240 | method: 'secrets_set', 241 | don_id: this.donId!, 242 | payload, 243 | }) 244 | 245 | let totalErrorCount = 0 246 | for (const nodeResponse of gatewayResponse.nodeResponses) { 247 | if (!nodeResponse.success) { 248 | console.log( 249 | `WARNING: Node connected to gateway URL ${gatewayResponse.gatewayUrl} failed to store the encrypted secrets:\n${nodeResponse}`, 250 | ) 251 | totalErrorCount++ 252 | } 253 | } 254 | 255 | if (totalErrorCount === gatewayResponse.nodeResponses.length) { 256 | throw Error('All nodes failed to store the encrypted secrets') 257 | } 258 | 259 | if (totalErrorCount > 0) { 260 | return { version: secretsVersion, success: false } 261 | } 262 | 263 | return { version: secretsVersion, success: true } 264 | } 265 | 266 | private validateGatewayUrls(gatewayUrls: string[]): void { 267 | if (!Array.isArray(gatewayUrls) || gatewayUrls.length === 0) { 268 | throw Error('gatewayUrls must be a non-empty array of strings') 269 | } 270 | 271 | for (const url of gatewayUrls) { 272 | try { 273 | new URL(url) 274 | } catch (e) { 275 | throw Error(`gatewayUrl ${url} is not a valid URL`) 276 | } 277 | } 278 | } 279 | 280 | private async sendMessageToGateways( 281 | gatewayRpcMessageConfig: GatewayMessageConfig, 282 | ): Promise { 283 | let gatewayResponse: GatewayResponse | undefined 284 | let i = 0 285 | for (const url of gatewayRpcMessageConfig.gatewayUrls) { 286 | i++ 287 | try { 288 | const response = await axios.post( 289 | url, 290 | await this.createGatewayMessage(gatewayRpcMessageConfig), 291 | ) 292 | if (!response.data?.result?.body?.payload?.success) { 293 | throw Error(`Gateway response indicated failure:\n${JSON.stringify(response.data)}`) 294 | } 295 | const nodeResponses = this.extractNodeResponses(response) 296 | gatewayResponse = { 297 | gatewayUrl: url, 298 | nodeResponses, 299 | } 300 | break // Break after first successful message is sent to a gateway 301 | } catch (e) { 302 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 303 | const error = e as any 304 | const errorResponseData = error?.response?.data 305 | console.log( 306 | `Error encountered when attempting to send request to DON gateway URL #${i} of ${ 307 | gatewayRpcMessageConfig.gatewayUrls.length 308 | }\n${url}:\n${errorResponseData ? JSON.stringify(errorResponseData) : error}`, 309 | ) 310 | } 311 | } 312 | 313 | if (!gatewayResponse) { 314 | throw Error( 315 | `Failed to send request to any of the DON gateway URLs:\n${JSON.stringify( 316 | gatewayRpcMessageConfig.gatewayUrls, 317 | )}`, 318 | ) 319 | } 320 | 321 | return gatewayResponse 322 | } 323 | 324 | private async createGatewayMessage({ 325 | method, 326 | don_id, 327 | payload, 328 | }: GatewayMessageConfig): Promise { 329 | const body = { 330 | message_id: `${Math.floor(Math.random() * Math.pow(2, 32))}`, 331 | method, 332 | don_id, 333 | receiver: '', 334 | payload, 335 | } 336 | 337 | const gatewaySignature = await this.signer.signMessage(this.createGatewayMessageBody(body)) 338 | 339 | return JSON.stringify({ 340 | id: body.message_id, 341 | jsonrpc: '2.0', 342 | method: body.method, 343 | params: { 344 | body, 345 | signature: gatewaySignature, 346 | }, 347 | }) 348 | } 349 | 350 | private createGatewayMessageBody({ 351 | message_id, 352 | method, 353 | don_id, 354 | receiver, 355 | payload, 356 | }: GatewayMessageBody): Buffer { 357 | const MessageIdMaxLen = 128 358 | const MessageMethodMaxLen = 64 359 | const MessageDonIdMaxLen = 64 360 | const MessageReceiverLen = 2 + 2 * 20 361 | 362 | const alignedMessageId = Buffer.alloc(MessageIdMaxLen) 363 | Buffer.from(message_id).copy(alignedMessageId) 364 | 365 | const alignedMethod = Buffer.alloc(MessageMethodMaxLen) 366 | Buffer.from(method).copy(alignedMethod) 367 | 368 | const alignedDonId = Buffer.alloc(MessageDonIdMaxLen) 369 | Buffer.from(don_id).copy(alignedDonId) 370 | 371 | const alignedReceiver = Buffer.alloc(MessageReceiverLen) 372 | Buffer.from(receiver).copy(alignedReceiver) 373 | 374 | let payloadJson = '' 375 | if (payload) { 376 | payloadJson = JSON.stringify(payload) 377 | } 378 | 379 | return Buffer.concat([ 380 | alignedMessageId, 381 | alignedMethod, 382 | alignedDonId, 383 | alignedReceiver, 384 | Buffer.from(payloadJson), 385 | ]) 386 | } 387 | 388 | private extractNodeResponses(gatewayResponse: AxiosResponse): NodeResponse[] { 389 | if (!gatewayResponse.data?.result?.body?.payload?.node_responses) { 390 | throw Error( 391 | `Unexpected response data from DON gateway:\n${JSON.stringify(gatewayResponse.data)}`, 392 | ) 393 | } 394 | if (gatewayResponse.data?.result?.body?.payload?.node_responses.length < 1) { 395 | throw Error('No nodes responded to gateway request') 396 | } 397 | const responses = gatewayResponse.data.result.body.payload.node_responses 398 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 399 | const nodeResponses: NodeResponse[] = responses.map((r: any) => { 400 | const nodeResponse: NodeResponse = { 401 | success: r.body.payload.success, 402 | } 403 | if (r.body.payload.rows) { 404 | nodeResponse.rows = r.body.payload.rows 405 | } 406 | return nodeResponse 407 | }) 408 | return nodeResponses 409 | } 410 | 411 | public async listDONHostedEncryptedSecrets( 412 | gatewayUrls: string[], 413 | ): Promise<{ result: GatewayResponse; error?: string }> { 414 | this.isInitialized() 415 | this.validateGatewayUrls(gatewayUrls) 416 | 417 | const gatewayResponse = await this.sendMessageToGateways({ 418 | gatewayUrls, 419 | method: 'secrets_list', 420 | don_id: this.donId!, 421 | }) 422 | 423 | try { 424 | this.verifyDONHostedSecrets([gatewayResponse]) 425 | return { result: gatewayResponse } 426 | } catch (e) { 427 | return { result: gatewayResponse, error: e?.toString() } 428 | } 429 | } 430 | 431 | private verifyDONHostedSecrets(gatewayResponses: GatewayResponse[]): void { 432 | // Create a single array of all the node responses 433 | const nodeResponses: NodeResponse[] = [] 434 | for (const gatewayResponse of gatewayResponses) { 435 | nodeResponses.push(...gatewayResponse.nodeResponses) 436 | } 437 | 438 | const didAllNodesReturnFailure = nodeResponses.every(nodeResponse => { 439 | return nodeResponse.success === false 440 | }) 441 | if (didAllNodesReturnFailure) { 442 | throw Error('All nodes returned a failure response') 443 | } 444 | 445 | // Verify that every node response was successful 446 | for (const nodeResponse of nodeResponses) { 447 | if (!nodeResponse.success) { 448 | throw Error( 449 | 'One or more nodes failed to respond to the request with a success confirmation', 450 | ) 451 | } 452 | } 453 | 454 | // Verify that every node responded with the same rows 455 | const rows = nodeResponses[0]!.rows 456 | for (const nodeResponse of nodeResponses) { 457 | if (!nodeResponse.rows || nodeResponse.rows.length !== rows!.length) { 458 | throw Error('One or more nodes responded with a different number of secrets entries') 459 | } 460 | for (let i = 0; i < rows!.length; i++) { 461 | if ( 462 | nodeResponse.rows[i].slot_id !== rows![i].slot_id || 463 | nodeResponse.rows[i].version !== rows![i].version || 464 | nodeResponse.rows[i].expiration !== rows![i].expiration 465 | ) { 466 | throw Error('One or more nodes responded with different secrets entries') 467 | } 468 | } 469 | } 470 | } 471 | 472 | public buildDONHostedEncryptedSecretsReference = ({ 473 | slotId, 474 | version, 475 | }: { 476 | slotId: number 477 | version: number 478 | }): string => { 479 | if (typeof slotId !== 'number' || slotId < 0 || !Number.isInteger(slotId)) { 480 | throw Error('Invalid slotId') 481 | } 482 | if (typeof version !== 'number' || version < 0 || !Number.isInteger(version)) { 483 | throw Error('Invalid version') 484 | } 485 | 486 | return ( 487 | '0x' + 488 | cbor 489 | .encodeCanonical({ 490 | slotId, 491 | version, 492 | }) 493 | .toString('hex') 494 | ) 495 | } 496 | } 497 | -------------------------------------------------------------------------------- /src/buildRequestCBOR.ts: -------------------------------------------------------------------------------- 1 | import cbor from 'cbor' 2 | import { utils } from 'ethers' 3 | 4 | import { Location, CodeLanguage } from './types' 5 | 6 | import type { FunctionsRequestParams } from './types' 7 | 8 | export const buildRequestCBOR = (requestParams: FunctionsRequestParams): string => { 9 | if ( 10 | typeof requestParams.codeLocation !== 'number' || 11 | requestParams.codeLocation !== Location.Inline 12 | ) { 13 | throw Error('Invalid codeLocation') 14 | } 15 | 16 | if ( 17 | typeof requestParams.codeLanguage !== 'number' || 18 | requestParams.codeLanguage !== CodeLanguage.JavaScript 19 | ) { 20 | throw Error('Invalid codeLanguage') 21 | } 22 | 23 | if (typeof requestParams.source !== 'string') { 24 | throw Error('Invalid source') 25 | } 26 | 27 | const request: { 28 | codeLocation: number 29 | secretsLocation?: number 30 | codeLanguage: number 31 | source: string 32 | secrets?: Buffer 33 | args?: string[] 34 | bytesArgs?: Buffer[] 35 | } = { 36 | codeLocation: requestParams.codeLocation, 37 | codeLanguage: requestParams.codeLanguage, 38 | source: requestParams.source, 39 | } 40 | 41 | if (requestParams.encryptedSecretsReference) { 42 | if (!utils.isHexString(requestParams.encryptedSecretsReference)) { 43 | throw Error('Invalid encryptedSecretsReference') 44 | } 45 | if ( 46 | typeof requestParams.secretsLocation !== 'number' || 47 | (requestParams.secretsLocation !== Location.DONHosted && 48 | requestParams.secretsLocation !== Location.Remote) 49 | ) { 50 | throw Error('Invalid secretsLocation') 51 | } 52 | request.secretsLocation = requestParams.secretsLocation 53 | request.secrets = Buffer.from(requestParams.encryptedSecretsReference.slice(2), 'hex') 54 | } 55 | 56 | if (requestParams.args) { 57 | if ( 58 | !Array.isArray(requestParams.args) || 59 | !requestParams.args.every(arg => typeof arg === 'string') 60 | ) { 61 | throw Error('Invalid args') 62 | } 63 | request.args = requestParams.args 64 | } 65 | 66 | if (requestParams.bytesArgs) { 67 | if ( 68 | !Array.isArray(requestParams.bytesArgs) || 69 | !requestParams.bytesArgs.every(arg => utils.isHexString(arg)) 70 | ) { 71 | throw Error('Invalid bytesArgs') 72 | } 73 | request.bytesArgs = requestParams.bytesArgs.map(arg => Buffer.from(arg.slice(2), 'hex')) 74 | } 75 | 76 | return '0x' + cbor.encodeCanonical(request).toString('hex') 77 | } 78 | -------------------------------------------------------------------------------- /src/decodeResult.ts: -------------------------------------------------------------------------------- 1 | import { ReturnType } from './types' 2 | 3 | export type DecodedResult = bigint | string 4 | 5 | export const decodeResult = ( 6 | resultHexstring: string, 7 | expectedReturnType: ReturnType, 8 | ): DecodedResult => { 9 | if (!isValidHexadecimal(resultHexstring) && resultHexstring.slice(0, 2) !== '0x') { 10 | throw Error(`'${resultHexstring}' is not a valid hexadecimal string`) 11 | } 12 | expectedReturnType = expectedReturnType.toLowerCase() as ReturnType 13 | 14 | if (!Object.values(ReturnType).includes(expectedReturnType)) { 15 | throw Error( 16 | `'${expectedReturnType}' is not valid. Must be one of the following: ${Object.values( 17 | ReturnType, 18 | )}`, 19 | ) 20 | } 21 | 22 | const resultHexBits = resultHexstring.slice(2).length * 4 23 | let decodedOutput 24 | switch (expectedReturnType) { 25 | case ReturnType.uint256: 26 | if (resultHexBits > 256) { 27 | throw Error( 28 | `'${resultHexstring}' has '${resultHexBits}' bits which is too large for uint256`, 29 | ) 30 | } 31 | if (resultHexstring === '0x') { 32 | return BigInt(0) 33 | } 34 | decodedOutput = BigInt('0x' + resultHexstring.slice(2).slice(-64)) 35 | break 36 | case ReturnType.int256: 37 | if (resultHexBits > 256) { 38 | throw Error( 39 | `'${resultHexstring}' has '${resultHexBits}' bits which is too large for int256`, 40 | ) 41 | } 42 | if (resultHexstring === '0x') { 43 | return BigInt(0) 44 | } 45 | decodedOutput = signedInt256toBigInt('0x' + resultHexstring.slice(2).slice(-64)) 46 | break 47 | case ReturnType.string: 48 | if (resultHexstring === '0x') { 49 | return '' 50 | } 51 | decodedOutput = Buffer.from(resultHexstring.slice(2), 'hex').toString() 52 | break 53 | case ReturnType.bytes: 54 | decodedOutput = resultHexstring 55 | break 56 | default: 57 | throw new Error(`unexpected return type to decode: '${expectedReturnType}'.`) 58 | } 59 | 60 | return decodedOutput as DecodedResult 61 | } 62 | 63 | const signedInt256toBigInt = (hex: string) => { 64 | const binary = BigInt(hex).toString(2).padStart(256, '0') 65 | // if the first bit is 0, number is positive 66 | if (binary[0] === '0') { 67 | return BigInt(hex) 68 | } 69 | return -(BigInt(2) ** BigInt(255)) + BigInt(`0b${binary.slice(1)}`) 70 | } 71 | 72 | const isValidHexadecimal = (str: string): boolean => { 73 | const regex = /^0x[0-9a-fA-F]+$/ 74 | return regex.test(str) 75 | } 76 | -------------------------------------------------------------------------------- /src/fetchRequestCommitment.ts: -------------------------------------------------------------------------------- 1 | import { Contract, utils } from 'ethers' 2 | 3 | import { FunctionsRouterSource, FunctionsCoordinatorSource } from './v1_contract_sources' 4 | 5 | import type { RequestCommitmentFetchConfig, RequestCommitment } from './types' 6 | 7 | export const fetchRequestCommitment = async ({ 8 | requestId, 9 | provider, 10 | functionsRouterAddress, 11 | donId, 12 | toBlock = 'latest', 13 | pastBlocksToSearch = 1000, 14 | }: RequestCommitmentFetchConfig): Promise => { 15 | let fromBlock: number 16 | const latestBlock = await provider.getBlockNumber() 17 | if (toBlock === 'latest') { 18 | fromBlock = latestBlock - pastBlocksToSearch 19 | } else { 20 | if (toBlock > latestBlock) { 21 | toBlock = latestBlock 22 | } 23 | fromBlock = toBlock - pastBlocksToSearch 24 | if (fromBlock < 0) { 25 | fromBlock = 0 26 | } 27 | } 28 | 29 | const functionsRouter = new Contract(functionsRouterAddress, FunctionsRouterSource.abi, provider) 30 | const donIdBytes32 = utils.formatBytes32String(donId) 31 | let functionsCoordinatorAddress: string 32 | try { 33 | functionsCoordinatorAddress = await functionsRouter.getContractById(donIdBytes32) 34 | } catch (error) { 35 | throw Error( 36 | `${error}\n\nError encountered when attempting to fetch the FunctionsCoordinator address.\nEnsure the FunctionsRouter address and donId are correct and that that the provider is able to connect to the blockchain.`, 37 | ) 38 | } 39 | 40 | const functionsCoordinator = new Contract( 41 | functionsCoordinatorAddress, 42 | FunctionsCoordinatorSource.abi, 43 | provider, 44 | ) 45 | const eventFilter = functionsCoordinator.filters.OracleRequest(requestId) 46 | const logs = await provider.getLogs({ 47 | ...eventFilter, 48 | fromBlock, 49 | toBlock, 50 | }) 51 | if (logs.length === 0) { 52 | throw Error( 53 | `No request commitment event found for the provided requestId in block range ${fromBlock} to ${toBlock}`, 54 | ) 55 | } 56 | 57 | const event = functionsCoordinator.interface.parseLog(logs[0]) 58 | const commitmentData = event.args.commitment 59 | const requestCommitment: RequestCommitment = { 60 | requestId: commitmentData.requestId, 61 | coordinator: commitmentData.coordinator, 62 | estimatedTotalCostJuels: BigInt(commitmentData.estimatedTotalCostJuels.toString()), 63 | client: commitmentData.client, 64 | subscriptionId: Number(commitmentData.subscriptionId.toString()), 65 | callbackGasLimit: BigInt(commitmentData.callbackGasLimit.toString()), 66 | adminFee: BigInt(commitmentData.adminFee.toString()), 67 | donFee: BigInt(commitmentData.donFee.toString()), 68 | gasOverheadBeforeCallback: BigInt(commitmentData.gasOverheadBeforeCallback.toString()), 69 | gasOverheadAfterCallback: BigInt(commitmentData.gasOverheadAfterCallback.toString()), 70 | timeoutTimestamp: BigInt(commitmentData.timeoutTimestamp.toString()), 71 | } 72 | return requestCommitment 73 | } 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | const oldLog = console.log 2 | // Suppress "Duplicate definition of Transfer" warning message 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | console.log = (...args: any[]) => { 5 | if (typeof args[0] === 'string') { 6 | const msg = args.length > 0 ? args[0] : '' 7 | if ( 8 | msg.includes('Duplicate definition of Transfer') || 9 | msg.includes('secp256k1 unavailable, reverting to browser version') 10 | ) { 11 | return 12 | } 13 | } 14 | oldLog(...args) 15 | } 16 | export * from './SubscriptionManager' 17 | export * from './SecretsManager' 18 | export * from './ResponseListener' 19 | export * from './simulateScript' 20 | export * from './localFunctionsTestnet' 21 | export * from './decodeResult' 22 | export * from './offchain_storage' 23 | export * from './types' 24 | export * from './buildRequestCBOR' 25 | export * from './simulationConfig' 26 | export * from './fetchRequestCommitment' 27 | -------------------------------------------------------------------------------- /src/localFunctionsTestnet.ts: -------------------------------------------------------------------------------- 1 | import { Wallet, Contract, ContractFactory, utils, providers } from 'ethers' 2 | import { Anvil, createAnvil } from '@viem/anvil' 3 | import cbor from 'cbor' 4 | 5 | import { simulateScript } from './simulateScript' 6 | import { 7 | simulatedRouterConfig, 8 | simulatedCoordinatorConfig, 9 | simulatedAllowListConfig, 10 | simulatedDonId, 11 | simulatedAllowListId, 12 | simulatedLinkEthPrice, 13 | callReportGasLimit, 14 | simulatedSecretsKeys, 15 | DEFAULT_MAX_ON_CHAIN_RESPONSE_BYTES, 16 | numberOfSimulatedNodeExecutions, 17 | simulatedLinkUsdPrice, 18 | } from './simulationConfig' 19 | import { 20 | LinkTokenSource, 21 | MockV3AggregatorSource, 22 | FunctionsRouterSource, 23 | FunctionsCoordinatorTestHelperSource, 24 | TermsOfServiceAllowListSource, 25 | } from './v1_contract_sources' 26 | 27 | import type { 28 | FunctionsRequestParams, 29 | RequestCommitment, 30 | LocalFunctionsTestnet, 31 | GetFunds, 32 | FunctionsContracts, 33 | RequestEventData, 34 | } from './types' 35 | 36 | // this chain id is defined here as a default: https://github.com/wevm/viem/blob/main/src/chains/definitions/localhost.ts#L4 37 | const chainId = 1337 38 | 39 | export const startLocalFunctionsTestnet = async ( 40 | simulationConfigPath?: string, 41 | port = 8545, 42 | ): Promise => { 43 | let anvil: Anvil 44 | try { 45 | anvil = createAnvil({ 46 | port, 47 | chainId: chainId, 48 | }) 49 | } catch (error) { 50 | console.error('Error creating Anvil instance: ', error) 51 | console.error( 52 | 'Please refer to README about how to properly set up the environment, including anvil.\n', 53 | ) 54 | throw error 55 | } 56 | 57 | await anvil.start() 58 | console.log(`Anvil started on port ${port} with chain ID 1337\n`) 59 | 60 | anvil.on('message', message => { 61 | console.log('Anvil message:', message) 62 | }) 63 | 64 | // this is a hardcoded private key provided by anvil, defined here: https://book.getfoundry.sh/anvil/#getting-started 65 | const privateKey = 66 | process.env.PRIVATE_KEY || 'ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' 67 | 68 | const admin = new Wallet(privateKey, new providers.JsonRpcProvider(`http://127.0.0.1:${port}`)) 69 | 70 | const contracts = await deployFunctionsOracle(admin) 71 | 72 | contracts.functionsMockCoordinatorContract.on( 73 | 'OracleRequest', 74 | ( 75 | requestId, 76 | requestingContract, 77 | requestInitiator, 78 | subscriptionId, 79 | subscriptionOwner, 80 | data, 81 | dataVersion, 82 | flags, 83 | callbackGasLimit, 84 | commitment, 85 | ) => { 86 | const requestEvent: RequestEventData = { 87 | requestId, 88 | requestingContract, 89 | requestInitiator, 90 | subscriptionId, 91 | subscriptionOwner, 92 | data, 93 | dataVersion, 94 | flags, 95 | callbackGasLimit, 96 | commitment, 97 | } 98 | handleOracleRequest( 99 | requestEvent, 100 | contracts.functionsMockCoordinatorContract, 101 | admin, 102 | simulationConfigPath, 103 | ) 104 | }, 105 | ) 106 | 107 | const getFunds: GetFunds = async (address, { weiAmount, juelsAmount }) => { 108 | if (!juelsAmount) { 109 | juelsAmount = BigInt(0) 110 | } 111 | if (!weiAmount) { 112 | weiAmount = BigInt(0) 113 | } 114 | if (typeof weiAmount !== 'string' && typeof weiAmount !== 'bigint') { 115 | throw Error(`weiAmount must be a BigInt or string, got ${typeof weiAmount}`) 116 | } 117 | if (typeof juelsAmount !== 'string' && typeof juelsAmount !== 'bigint') { 118 | throw Error(`juelsAmount must be a BigInt or string, got ${typeof juelsAmount}`) 119 | } 120 | weiAmount = BigInt(weiAmount) 121 | juelsAmount = BigInt(juelsAmount) 122 | const ethTx = await admin.sendTransaction({ 123 | to: address, 124 | value: weiAmount.toString(), 125 | }) 126 | const linkTx = await contracts.linkTokenContract.connect(admin).transfer(address, juelsAmount) 127 | await ethTx.wait(1) 128 | await linkTx.wait(1) 129 | console.log( 130 | `Sent ${utils.formatEther(weiAmount.toString())} ETH and ${utils.formatEther( 131 | juelsAmount.toString(), 132 | )} LINK to ${address}`, 133 | ) 134 | } 135 | 136 | const close = async (): Promise => { 137 | contracts.functionsMockCoordinatorContract.removeAllListeners('OracleRequest') 138 | await anvil.stop() 139 | } 140 | 141 | return { 142 | anvil, 143 | adminWallet: { 144 | address: admin.address, 145 | privateKey: admin.privateKey, 146 | }, 147 | ...contracts, 148 | getFunds, 149 | close, 150 | } 151 | } 152 | 153 | const handleOracleRequest = async ( 154 | requestEventData: RequestEventData, 155 | mockCoordinator: Contract, 156 | admin: Wallet, 157 | simulationConfigPath?: string, 158 | ) => { 159 | const response = await simulateDONExecution(requestEventData, simulationConfigPath) 160 | 161 | const errorHexstring = response.errorString 162 | ? '0x' + Buffer.from(response.errorString.toString()).toString('hex') 163 | : undefined 164 | const encodedReport = encodeReport( 165 | requestEventData.requestId, 166 | requestEventData.commitment, 167 | response.responseBytesHexstring, 168 | errorHexstring, 169 | ) 170 | 171 | const reportTx = await mockCoordinator 172 | .connect(admin) 173 | .callReport(encodedReport, { gasLimit: callReportGasLimit }) 174 | await reportTx.wait(1) 175 | } 176 | 177 | const simulateDONExecution = async ( 178 | requestEventData: RequestEventData, 179 | simulationConfigPath?: string, 180 | ): Promise<{ responseBytesHexstring?: string; errorString?: string }> => { 181 | let requestData: FunctionsRequestParams 182 | try { 183 | requestData = await buildRequestObject(requestEventData.data) 184 | } catch { 185 | return { 186 | errorString: 'CBOR parsing error', 187 | } 188 | } 189 | 190 | const simulationConfig = simulationConfigPath ? require(simulationConfigPath) : {} 191 | 192 | // Perform the simulation numberOfSimulatedNodeExecution times 193 | const simulations = [...Array(numberOfSimulatedNodeExecutions)].map(async () => { 194 | try { 195 | return await simulateScript({ 196 | source: requestData.source, 197 | secrets: simulationConfig.secrets, // Secrets are taken from simulationConfig, not request data included in transaction 198 | args: requestData.args, 199 | bytesArgs: requestData.bytesArgs, 200 | maxOnChainResponseBytes: simulationConfig.maxOnChainResponseBytes, 201 | maxExecutionTimeMs: simulationConfig.maxExecutionTimeMs, 202 | maxMemoryUsageMb: simulationConfig.maxMemoryUsageMb, 203 | numAllowedQueries: simulationConfig.numAllowedQueries, 204 | maxQueryDurationMs: simulationConfig.maxQueryDurationMs, 205 | maxQueryUrlLength: simulationConfig.maxQueryUrlLength, 206 | maxQueryRequestBytes: simulationConfig.maxQueryRequestBytes, 207 | maxQueryResponseBytes: simulationConfig.maxQueryResponseBytes, 208 | }) 209 | } catch (err) { 210 | const errorString = (err as Error).message.slice( 211 | 0, 212 | simulationConfig.maxOnChainResponseBytes ?? DEFAULT_MAX_ON_CHAIN_RESPONSE_BYTES, 213 | ) 214 | return { 215 | errorString, 216 | capturedTerminalOutput: '', 217 | } 218 | } 219 | }) 220 | const responses = await Promise.all(simulations) 221 | 222 | const successfulResponses = responses.filter(response => response.errorString === undefined) 223 | const errorResponses = responses.filter(response => response.errorString !== undefined) 224 | 225 | if (successfulResponses.length > errorResponses.length) { 226 | return { 227 | responseBytesHexstring: aggregateMedian( 228 | successfulResponses.map(response => response.responseBytesHexstring!), 229 | ), 230 | } 231 | } else { 232 | return { 233 | errorString: aggregateModeString(errorResponses.map(response => response.errorString!)), 234 | } 235 | } 236 | } 237 | 238 | const aggregateMedian = (responses: string[]): string => { 239 | const bufResponses = responses.map(response => Buffer.from(response.slice(2), 'hex')) 240 | 241 | bufResponses.sort((a, b) => { 242 | if (a.length !== b.length) { 243 | return a.length - b.length 244 | } 245 | for (let i = 0; i < a.length; ++i) { 246 | if (a[i] !== b[i]) { 247 | return a[i] - b[i] 248 | } 249 | } 250 | return 0 251 | }) 252 | 253 | return '0x' + bufResponses[Math.floor((bufResponses.length - 1) / 2)].toString('hex') 254 | } 255 | 256 | const aggregateModeString = (items: string[]): string => { 257 | const counts = new Map() 258 | 259 | for (const str of items) { 260 | const existingCount = counts.get(str) || 0 261 | counts.set(str, existingCount + 1) 262 | } 263 | 264 | let modeString = items[0] 265 | let maxCount = counts.get(modeString) || 0 266 | 267 | for (const [str, count] of counts.entries()) { 268 | if (count > maxCount) { 269 | maxCount = count 270 | modeString = str 271 | } 272 | } 273 | 274 | return modeString 275 | } 276 | 277 | const encodeReport = ( 278 | requestId: string, 279 | commitment: RequestCommitment, 280 | result?: string, 281 | error?: string, 282 | ): string => { 283 | const encodedCommitment = utils.defaultAbiCoder.encode( 284 | [ 285 | 'bytes32', 286 | 'address', 287 | 'uint96', 288 | 'address', 289 | 'uint64', 290 | 'uint32', 291 | 'uint72', 292 | 'uint72', 293 | 'uint40', 294 | 'uint40', 295 | 'uint32', 296 | ], 297 | [ 298 | commitment.requestId, 299 | commitment.coordinator, 300 | commitment.estimatedTotalCostJuels, 301 | commitment.client, 302 | commitment.subscriptionId, 303 | commitment.callbackGasLimit, 304 | commitment.adminFee, 305 | commitment.donFee, 306 | commitment.gasOverheadBeforeCallback, 307 | commitment.gasOverheadAfterCallback, 308 | commitment.timeoutTimestamp, 309 | ], 310 | ) 311 | const encodedReport = utils.defaultAbiCoder.encode( 312 | ['bytes32[]', 'bytes[]', 'bytes[]', 'bytes[]', 'bytes[]'], 313 | [[requestId], [result ?? []], [error ?? []], [encodedCommitment], [[]]], 314 | ) 315 | return encodedReport 316 | } 317 | 318 | const buildRequestObject = async ( 319 | requestDataHexString: string, 320 | ): Promise => { 321 | const decodedRequestData = await cbor.decodeAll(Buffer.from(requestDataHexString.slice(2), 'hex')) 322 | 323 | if (typeof decodedRequestData[0] === 'object') { 324 | if (decodedRequestData[0].bytesArgs) { 325 | decodedRequestData[0].bytesArgs = decodedRequestData[0].bytesArgs?.map((bytesArg: Buffer) => { 326 | return '0x' + bytesArg?.toString('hex') 327 | }) 328 | } 329 | decodedRequestData[0].secrets = undefined 330 | return decodedRequestData[0] as FunctionsRequestParams 331 | } 332 | const requestDataObject = {} as FunctionsRequestParams 333 | // The decoded request data is an array of alternating keys and values, therefore we can iterate over it in steps of 2 334 | for (let i = 0; i < decodedRequestData.length - 1; i += 2) { 335 | const requestDataKey = decodedRequestData[i] 336 | const requestDataValue = decodedRequestData[i + 1] 337 | switch (requestDataKey) { 338 | case 'codeLocation': 339 | requestDataObject.codeLocation = requestDataValue 340 | break 341 | case 'secretsLocation': 342 | // Unused as secrets provided as an argument to startLocalFunctionsTestnet() are used instead 343 | break 344 | case 'language': 345 | requestDataObject.codeLanguage = requestDataValue 346 | break 347 | case 'source': 348 | requestDataObject.source = requestDataValue 349 | break 350 | case 'secrets': 351 | // Unused as secrets provided as an argument to startLocalFunctionsTestnet() are used instead 352 | break 353 | case 'args': 354 | requestDataObject.args = requestDataValue 355 | break 356 | case 'bytesArgs': 357 | requestDataObject.bytesArgs = requestDataValue?.map((bytesArg: Buffer) => { 358 | return '0x' + bytesArg?.toString('hex') 359 | }) 360 | break 361 | default: 362 | // Ignore unknown keys 363 | } 364 | } 365 | 366 | return requestDataObject 367 | } 368 | 369 | export const deployFunctionsOracle = async (deployer: Wallet): Promise => { 370 | const linkTokenFactory = new ContractFactory( 371 | LinkTokenSource.abi, 372 | LinkTokenSource.bytecode, 373 | deployer, 374 | ) 375 | const linkToken = await linkTokenFactory.connect(deployer).deploy() 376 | 377 | const linkPriceFeedFactory = new ContractFactory( 378 | MockV3AggregatorSource.abi, 379 | MockV3AggregatorSource.bytecode, 380 | deployer, 381 | ) 382 | const linkEthPriceFeed = await linkPriceFeedFactory 383 | .connect(deployer) 384 | .deploy(18, simulatedLinkEthPrice) 385 | const linkUsdPriceFeed = await linkPriceFeedFactory 386 | .connect(deployer) 387 | .deploy(8, simulatedLinkUsdPrice) 388 | 389 | const routerFactory = new ContractFactory( 390 | FunctionsRouterSource.abi, 391 | FunctionsRouterSource.bytecode, 392 | deployer, 393 | ) 394 | const router = await routerFactory 395 | .connect(deployer) 396 | .deploy(linkToken.address, simulatedRouterConfig) 397 | 398 | const mockCoordinatorFactory = new ContractFactory( 399 | FunctionsCoordinatorTestHelperSource.abi, 400 | FunctionsCoordinatorTestHelperSource.bytecode, 401 | deployer, 402 | ) 403 | const mockCoordinator = await mockCoordinatorFactory 404 | .connect(deployer) 405 | .deploy( 406 | router.address, 407 | simulatedCoordinatorConfig, 408 | linkEthPriceFeed.address, 409 | linkUsdPriceFeed.address, 410 | ) 411 | 412 | const allowlistFactory = new ContractFactory( 413 | TermsOfServiceAllowListSource.abi, 414 | TermsOfServiceAllowListSource.bytecode, 415 | deployer, 416 | ) 417 | const initialAllowedSenders: string[] = [] 418 | const initialBlockedSenders: string[] = [] 419 | const allowlist = await allowlistFactory 420 | .connect(deployer) 421 | .deploy(simulatedAllowListConfig, initialAllowedSenders, initialBlockedSenders) 422 | 423 | const setAllowListIdTx = await router.setAllowListId( 424 | utils.formatBytes32String(simulatedAllowListId), 425 | ) 426 | await setAllowListIdTx.wait(1) 427 | 428 | const allowlistId = await router.getAllowListId() 429 | const proposeContractsTx = await router.proposeContractsUpdate( 430 | [allowlistId, utils.formatBytes32String(simulatedDonId)], 431 | [allowlist.address, mockCoordinator.address], 432 | { 433 | gasLimit: 1_000_000, 434 | }, 435 | ) 436 | await proposeContractsTx.wait(1) 437 | await router.updateContracts({ gasLimit: 1_000_000 }) 438 | 439 | await mockCoordinator.connect(deployer).setDONPublicKey(simulatedSecretsKeys.donKey.publicKey) 440 | await mockCoordinator 441 | .connect(deployer) 442 | .setThresholdPublicKey( 443 | '0x' + Buffer.from(simulatedSecretsKeys.thresholdKeys.publicKey).toString('hex'), 444 | ) 445 | 446 | return { 447 | donId: simulatedDonId, 448 | linkTokenContract: linkToken, 449 | functionsRouterContract: router, 450 | functionsMockCoordinatorContract: mockCoordinator, 451 | } 452 | } 453 | -------------------------------------------------------------------------------- /src/offchain_storage/github.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const createGist = async (githubApiToken: string, content: string): Promise => { 4 | if (typeof content !== 'string') { 5 | throw new Error('Gist content must be a string') 6 | } 7 | 8 | if (!githubApiToken) { 9 | throw new Error('Github API token is required') 10 | } 11 | 12 | await checkTokenGistScope(githubApiToken) 13 | 14 | const headers = { 15 | Authorization: `token ${githubApiToken}`, 16 | } 17 | 18 | // construct the API endpoint for creating a Gist 19 | const url = 'https://api.github.com/gists' 20 | const body = { 21 | public: false, 22 | files: { 23 | [`encrypted-functions-request-data-${Date.now()}.json`]: { 24 | content: content, 25 | }, 26 | }, 27 | } 28 | 29 | try { 30 | const response = await axios.post(url, body, { headers }) 31 | const gistUrl = response.data.html_url + '/raw' 32 | return gistUrl 33 | } catch (error) { 34 | throw Error(`Failed to create Gist: ${error}`) 35 | } 36 | } 37 | 38 | const checkTokenGistScope = async (githubApiToken: string) => { 39 | const headers = { 40 | Authorization: `Bearer ${githubApiToken}`, 41 | } 42 | 43 | let response 44 | try { 45 | response = await axios.get('https://api.github.com/user', { headers }) 46 | } catch (error) { 47 | throw new Error( 48 | `Failed to get token data. Check that your access token is valid. Error: ${error}`, 49 | ) 50 | } 51 | 52 | // Github's newly-added fine-grained token do not currently allow for verifying that the token scope is restricted to Gists. 53 | // This verification feature only works with classic Github tokens and is otherwise ignored 54 | const scopes = response.headers['x-oauth-scopes']?.split(', ') 55 | 56 | if (scopes && scopes?.[0] !== 'gist') { 57 | throw Error('The provided Github API token does not have permissions to read and write Gists') 58 | } 59 | 60 | if (scopes && scopes.length > 1) { 61 | console.log( 62 | 'WARNING: The provided Github API token has additional permissions beyond reading and writing to Gists', 63 | ) 64 | } 65 | 66 | return true 67 | } 68 | 69 | export const deleteGist = async (githubApiToken: string, gistURL: string) => { 70 | const headers = { 71 | Authorization: `Bearer ${githubApiToken}`, 72 | } 73 | 74 | if (!githubApiToken) { 75 | throw Error('Github API token is required') 76 | } 77 | 78 | if (!gistURL) { 79 | throw Error('Github Gist URL is required') 80 | } 81 | 82 | const matchArr = gistURL.match(/gist\.github\.com\/[^/]+\/([a-zA-Z0-9]+)/) 83 | 84 | if (!matchArr || !matchArr[1]) { 85 | throw Error('Invalid Gist URL') 86 | } 87 | 88 | const gistId = matchArr[1] 89 | 90 | try { 91 | await axios.delete(`https://api.github.com/gists/${gistId}`, { headers }) 92 | return true 93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 | } catch (error: any) { 95 | throw Error(`Error deleting Gist ${gistURL} : ${error}`) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/offchain_storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github' 2 | -------------------------------------------------------------------------------- /src/simulateScript/Functions.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import type { AxiosResponse, AxiosError } from 'axios' 3 | import { safePow } from './safePow' 4 | 5 | interface RequestOptions { 6 | url: string 7 | method?: HttpMethod 8 | params?: Record 9 | headers?: Record 10 | data?: Record 11 | timeout?: number 12 | responseType?: ResponseType 13 | } 14 | 15 | type HttpMethod = 'get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' 16 | 17 | type ResponseType = 'json' | 'arraybuffer' | 'document' | 'text' | 'stream' 18 | 19 | type HttpResponse = SuccessHttpResponse | ErrorHttpResponse 20 | 21 | interface SuccessHttpResponse extends AxiosResponse { 22 | error: false 23 | config: never 24 | } 25 | 26 | interface ErrorHttpResponse extends AxiosError { 27 | error: true 28 | config: never 29 | } 30 | 31 | export interface UserHttpQuery { 32 | url?: string 33 | method?: string 34 | timeout?: number 35 | success?: boolean 36 | } 37 | 38 | export class FunctionsModule { 39 | public buildFunctionsmodule = (numAllowedQueries: number) => { 40 | return { 41 | makeHttpRequest: this.makeHttpRequestFactory(numAllowedQueries), 42 | encodeUint256: FunctionsModule.encodeUint256, 43 | encodeInt256: FunctionsModule.encodeInt256, 44 | encodeString: FunctionsModule.encodeString, 45 | } 46 | } 47 | 48 | private makeHttpRequestFactory = (maxHttpRequests: number) => { 49 | let totalHttpRequests = 0 50 | return async ({ 51 | url, 52 | method = 'get', 53 | params, 54 | headers, 55 | data, 56 | timeout = 5000, 57 | responseType = 'json', 58 | }: RequestOptions): Promise => { 59 | if (totalHttpRequests < maxHttpRequests) { 60 | totalHttpRequests++ 61 | let result 62 | 63 | if (timeout > 9000) { 64 | throw Error('HTTP request timeout >9000') 65 | } 66 | 67 | if (url.length > 2048) { 68 | throw Error('HTTP request URL length >2048') 69 | } 70 | 71 | try { 72 | result = (await axios({ 73 | method: method.toLowerCase(), 74 | url, 75 | params, 76 | headers, 77 | data, 78 | timeout, 79 | responseType, 80 | maxBodyLength: 2000, // Max request size of 2 kilobytes 81 | maxContentLength: 2000000, // Max response size: 2 megabytes 82 | })) as SuccessHttpResponse 83 | delete result.request 84 | delete result.config 85 | result.error = false 86 | return result 87 | } catch (untypedError) { 88 | const error = untypedError as ErrorHttpResponse 89 | delete error.request 90 | delete error.config 91 | if (error.response) { 92 | delete error.response.request 93 | } 94 | error.error = true 95 | return error 96 | } 97 | } 98 | throw Error('exceeded numAllowedQueries') 99 | } 100 | } 101 | 102 | static encodeUint256 = (result: number | bigint): Buffer => { 103 | if (typeof result === 'number') { 104 | if (!Number.isInteger(result)) { 105 | throw Error('encodeUint256 invalid input') 106 | } 107 | 108 | if (result < 0) { 109 | throw Error('encodeUint256 invalid input') 110 | } 111 | 112 | return FunctionsModule.encodeUint256(BigInt(result)) 113 | } 114 | 115 | if (typeof result === 'bigint') { 116 | if (result > FunctionsModule.maxUint256) { 117 | throw Error('encodeUint256 invalid input') 118 | } 119 | 120 | if (result < BigInt(0)) { 121 | throw Error('encodeUint256 invalid input') 122 | } 123 | 124 | if (result === BigInt(0)) { 125 | return Buffer.from( 126 | '0000000000000000000000000000000000000000000000000000000000000000', 127 | 'hex', 128 | ) 129 | } 130 | 131 | const hex = result.toString(16).padStart(64, '0') 132 | 133 | return Buffer.from(hex, 'hex') 134 | } 135 | 136 | throw Error('encodeUint256 invalid input') 137 | } 138 | 139 | static encodeInt256 = (result: number | bigint): Buffer => { 140 | if (typeof result === 'number') { 141 | if (!Number.isInteger(result)) { 142 | throw Error('encodeInt256 invalid input') 143 | } 144 | 145 | return FunctionsModule.encodeInt256(BigInt(result)) 146 | } 147 | 148 | if (typeof result !== 'bigint') { 149 | throw Error('encodeInt256 invalid input') 150 | } 151 | 152 | if (result < FunctionsModule.maxNegInt256) { 153 | throw Error('encodeInt256 invalid input') 154 | } 155 | 156 | if (result > FunctionsModule.maxPosInt256) { 157 | throw Error('encodeInt256 invalid input') 158 | } 159 | 160 | if (result < BigInt(0)) { 161 | return FunctionsModule.encodeNegSignedInt(result) 162 | } 163 | 164 | return FunctionsModule.encodePosSignedInt(result) 165 | } 166 | 167 | static encodeString = (result: string): Buffer => { 168 | if (typeof result !== 'string') { 169 | throw Error('encodeString invalid input') 170 | } 171 | 172 | return Buffer.from(result) 173 | } 174 | 175 | static encodePosSignedInt = (int: bigint): Buffer => { 176 | const hex = int.toString(16).padStart(64, '0') 177 | return Buffer.from(hex, 'hex') 178 | } 179 | 180 | static encodeNegSignedInt = (int: bigint): Buffer => { 181 | const overflowing = safePow(BigInt(2), BigInt(256)) + int 182 | const overflowingHex = overflowing.toString(16) 183 | const int256Hex = overflowingHex.slice(-64) 184 | return Buffer.from(int256Hex, 'hex') 185 | } 186 | 187 | static maxUint256 = BigInt( 188 | '115792089237316195423570985008687907853269984665640564039457584007913129639935', 189 | ) 190 | static maxPosInt256 = BigInt( 191 | '57896044618658097711785492504343953926634992332820282019728792003956564819967', 192 | ) 193 | static maxNegInt256 = BigInt( 194 | '-57896044618658097711785492504343953926634992332820282019728792003956564819968', 195 | ) 196 | } 197 | -------------------------------------------------------------------------------- /src/simulateScript/deno-sandbox/sandbox.ts: -------------------------------------------------------------------------------- 1 | // secrets, args & bytesArgs are made available to the user's script 2 | // deno-lint-ignore no-unused-vars 3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 4 | const [secrets, args, bytesArgs] = [ 5 | JSON.parse(atob(Deno.args[0])), 6 | JSON.parse(atob(Deno.args[1])), 7 | JSON.parse(atob(Deno.args[2])), 8 | ] 9 | 10 | const ___1__ = fetch 11 | 12 | const __2___ = (() => { 13 | class Proxy { 14 | private server?: Deno.Listener 15 | private conns: Deno.Conn[] = [] 16 | private httpConns: Deno.HttpConn[] = [] 17 | private fetchControllers: AbortController[] = [] 18 | private httpQueryCount = 0 19 | 20 | constructor( 21 | private maxHttpQueries: number, 22 | private maxHttpQueryDurationMs: number, 23 | private maxHttpQueryUrlLength: number, 24 | private maxHttpQueryRequestBytes: number, 25 | private maxHttpQueryResponseBytes: number, 26 | public port?: number, 27 | ) { 28 | this.server = Deno.listen({ port: port ?? 0 }) 29 | ;(async () => { 30 | for await (const conn of this.server!) { 31 | this.conns.push(conn) 32 | const httpConn = Deno.serveHttp(conn) 33 | this.httpConns.push(httpConn) 34 | this.handleHttpConnection(httpConn) 35 | } 36 | })() 37 | this.port = (this.server.addr as Deno.NetAddr).port 38 | } 39 | 40 | private handleHttpConnection = async (httpConn: Deno.HttpConn): Promise => { 41 | for await (const requestEvent of httpConn) { 42 | try { 43 | const response = await this.handleProxyRequest(requestEvent) 44 | await requestEvent.respondWith(response) 45 | } catch { 46 | // Client may have already closed connection 47 | } 48 | } 49 | } 50 | 51 | private handleProxyRequest = async (requestEvent: Deno.RequestEvent): Promise => { 52 | let proxyRequestTimeout: number | undefined 53 | try { 54 | this.httpQueryCount++ 55 | if (this.httpQueryCount > this.maxHttpQueries) { 56 | return new Response( 57 | JSON.stringify({ error: `Exceeded maximum of ${this.maxHttpQueries} HTTP queries` }), 58 | { 59 | status: 429, 60 | }, 61 | ) 62 | } 63 | 64 | const originalUrl = new URL(requestEvent.request.url) 65 | const targetUrlParam = originalUrl.searchParams.get('target') 66 | if (!targetUrlParam) { 67 | return new Response(JSON.stringify({ error: 'Missing target URL parameter' }), { 68 | status: 400, 69 | }) 70 | } 71 | 72 | const targetUrl = decodeURIComponent(targetUrlParam) 73 | if (targetUrl.toString().length > this.maxHttpQueryUrlLength) { 74 | return new Response( 75 | JSON.stringify({ 76 | error: `Destination URL exceeds maximum length of ${this.maxHttpQueryUrlLength}`, 77 | }), 78 | { status: 414 }, 79 | ) 80 | } 81 | 82 | const { result: proxyReqBody, wasSizeExceeded: wasReqPayloadSizeExceeded } = 83 | await this.readStreamWithLimit(requestEvent.request.body, this.maxHttpQueryRequestBytes) 84 | if (wasReqPayloadSizeExceeded) { 85 | return new Response( 86 | JSON.stringify({ 87 | error: `Request payload size exceeds ${this.maxHttpQueryRequestBytes} byte limit`, 88 | }), 89 | { status: 413 }, 90 | ) 91 | } 92 | 93 | const controller = new AbortController() 94 | this.fetchControllers.push(controller) 95 | proxyRequestTimeout = setTimeout(() => controller.abort(), this.maxHttpQueryDurationMs) 96 | 97 | const proxyFetch = await ___1__(targetUrl, { 98 | body: proxyReqBody ? proxyReqBody : undefined, 99 | cache: requestEvent.request.cache, 100 | credentials: requestEvent.request.credentials, 101 | headers: requestEvent.request.headers, 102 | integrity: requestEvent.request.integrity, 103 | keepalive: requestEvent.request.keepalive, 104 | method: requestEvent.request.method, 105 | mode: requestEvent.request.mode, 106 | redirect: requestEvent.request.redirect, 107 | referrer: requestEvent.request.referrer, 108 | referrerPolicy: requestEvent.request.referrerPolicy, 109 | signal: controller.signal, 110 | }) 111 | clearTimeout(proxyRequestTimeout) 112 | 113 | const { result: proxyFetchBody, wasSizeExceeded: wasResBodySizeExceeded } = 114 | await this.readStreamWithLimit(proxyFetch.body, this.maxHttpQueryResponseBytes) 115 | if (wasResBodySizeExceeded) { 116 | return new Response( 117 | JSON.stringify({ 118 | error: `Response payload size exceeds ${this.maxHttpQueryResponseBytes} byte limit`, 119 | }), 120 | { status: 413 }, 121 | ) 122 | } 123 | 124 | return new Response(proxyFetchBody ? proxyFetchBody : undefined, { 125 | headers: proxyFetch.headers, 126 | status: proxyFetch.status, 127 | statusText: proxyFetch.statusText, 128 | }) 129 | } catch (e) { 130 | proxyRequestTimeout ? clearTimeout(proxyRequestTimeout) : null 131 | if (e?.name === 'AbortError') { 132 | return new Response( 133 | JSON.stringify({ 134 | error: `HTTP query exceeded time limit of ${this.maxHttpQueryDurationMs}ms`, 135 | }), 136 | { 137 | status: 400, 138 | }, 139 | ) 140 | } 141 | return new Response(JSON.stringify({ error: 'Error during fetch request' }), { 142 | status: 400, 143 | }) 144 | } 145 | } 146 | 147 | private readStreamWithLimit = async ( 148 | stream: ReadableStream | null, 149 | maxPayloadSize: number, 150 | ): Promise<{ result: Uint8Array | null; wasSizeExceeded: boolean }> => { 151 | if (maxPayloadSize > 2 ** 32) { 152 | throw new Error('maxPayloadSize must be less than 2^32') 153 | } 154 | if (!stream) { 155 | return { result: null, wasSizeExceeded: false } 156 | } 157 | 158 | const reader = stream.getReader() 159 | const chunks: Uint8Array[] = [] 160 | let totalLength = 0 161 | // eslint-disable-next-line no-constant-condition 162 | while (true) { 163 | const { done, value } = await reader.read() 164 | if (value) { 165 | if (value.length + totalLength > maxPayloadSize) { 166 | await this.cancelReaderAndStream(stream, reader) 167 | return { result: null, wasSizeExceeded: true } 168 | } 169 | chunks.push(value) 170 | totalLength += value.length 171 | } 172 | if (done) { 173 | break 174 | } 175 | } 176 | 177 | await this.cancelReaderAndStream(stream, reader) 178 | 179 | if (chunks.length === 0) { 180 | return { result: null, wasSizeExceeded: false } 181 | } 182 | 183 | const payload = new Uint8Array(totalLength) 184 | let offset = 0 185 | for (const chunk of chunks) { 186 | payload.set(chunk, offset) 187 | offset += chunk.length 188 | } 189 | return { result: payload, wasSizeExceeded: false } 190 | } 191 | 192 | private cancelReaderAndStream = async ( 193 | stream: ReadableStream, 194 | reader: ReadableStreamDefaultReader, 195 | ): Promise => { 196 | try { 197 | reader.releaseLock() 198 | } catch { 199 | // Reader may have already been released 200 | } 201 | try { 202 | await reader.cancel() 203 | } catch { 204 | // Reader may have already been canceled 205 | } 206 | try { 207 | await stream.cancel() 208 | } catch { 209 | // Stream may have already been canceled 210 | } 211 | } 212 | 213 | public close = (): void => { 214 | this.server?.close() 215 | this.conns.forEach(conn => { 216 | try { 217 | conn.close() 218 | } catch { 219 | // Client may have already closed connection 220 | } 221 | }) 222 | this.httpConns.forEach(httpConn => { 223 | try { 224 | httpConn.close() 225 | } catch { 226 | // Client may have already closed connection 227 | } 228 | }) 229 | // Abort any pending fetches when the server is closed 230 | this.fetchControllers.forEach(controller => { 231 | try { 232 | controller.abort() 233 | } catch { 234 | // Controller may have already been aborted 235 | } 236 | }) 237 | } 238 | } 239 | 240 | return new Proxy( 241 | Number(Deno.args[3]), // numAllowedQueries 242 | Number(Deno.args[4]), // maxQueryDurationMs 243 | Number(Deno.args[5]), // maxQueryUrlLength 244 | Number(Deno.args[6]), // maxQueryRequestBytes 245 | Number(Deno.args[7]), // maxQueryResponseBytes 246 | ) 247 | })() 248 | 249 | // Expose a modified version fetch function which routes all requests through the proxy 250 | globalThis.fetch = (input: string | Request | URL, init?: RequestInit | undefined) => { 251 | const url = new URL(`http://localhost:${__2___.port}`) 252 | if (input instanceof Request) { 253 | url.searchParams.append('target', encodeURIComponent(input.url.toString())) 254 | input = { 255 | ...input, 256 | url: url.toString(), 257 | } 258 | } else if (typeof input === 'string' || input instanceof URL) { 259 | url.searchParams.append('target', encodeURIComponent(input.toString())) 260 | input = url.toString() 261 | } else { 262 | throw new Error('fetch only accepts string, URL or Request object') 263 | } 264 | return ___1__(input, init) 265 | } 266 | 267 | interface RequestOptions { 268 | url: string 269 | method?: HttpMethod 270 | params?: Record 271 | headers?: Record 272 | data?: Record 273 | timeout?: number 274 | responseType?: ResponseType 275 | } 276 | 277 | type HttpMethod = 'get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' 278 | 279 | type ResponseType = 'json' | 'arraybuffer' | 'document' | 'text' | 'stream' 280 | 281 | interface SuccessResponse { 282 | error: false 283 | data?: unknown 284 | status: number 285 | statusText: string 286 | headers?: Record 287 | } 288 | 289 | interface ErrorResponse { 290 | error: true 291 | message?: string 292 | code?: string 293 | response?: Response 294 | } 295 | 296 | // Functions library for use by user's script 297 | // deno-lint-ignore no-unused-vars 298 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 299 | const Functions = { 300 | makeHttpRequest: async ({ 301 | url, 302 | method = 'get', 303 | params, 304 | headers, 305 | data, 306 | timeout = 3000, 307 | responseType = 'json', 308 | }: RequestOptions): Promise => { 309 | try { 310 | if (params) { 311 | url += '?' + new URLSearchParams(params).toString() 312 | } 313 | 314 | // Setup controller for timeout 315 | const controller = new AbortController() 316 | const id = setTimeout(() => controller.abort(), timeout) 317 | const result = await fetch(url, { 318 | method, 319 | headers, 320 | body: data ? JSON.stringify(data) : undefined, 321 | signal: controller.signal, 322 | }) 323 | clearTimeout(id) 324 | 325 | if (result.status >= 400) { 326 | const errorResponse: ErrorResponse = { 327 | error: true, 328 | message: result.statusText, 329 | code: result.status.toString(), 330 | response: result, 331 | } 332 | return errorResponse 333 | } 334 | 335 | const successResponse: SuccessResponse = { 336 | error: false, 337 | status: result.status, 338 | statusText: result.statusText, 339 | headers: result.headers ? Object.fromEntries(result.headers.entries()) : undefined, 340 | } 341 | 342 | switch (responseType) { 343 | case 'json': 344 | successResponse.data = await result.json() 345 | break 346 | case 'arraybuffer': 347 | successResponse.data = await result.arrayBuffer() 348 | break 349 | case 'document': 350 | successResponse.data = await result.text() 351 | break 352 | case 'text': 353 | successResponse.data = await result.text() 354 | break 355 | case 'stream': 356 | successResponse.data = result.body 357 | break 358 | default: 359 | throw new Error('invalid response type') 360 | } 361 | 362 | return successResponse 363 | } catch (e) { 364 | return { 365 | error: true, 366 | message: e?.toString?.(), 367 | } 368 | } 369 | }, 370 | 371 | encodeUint256: (num: bigint | number): Uint8Array => { 372 | if (typeof num !== 'number' && typeof num !== 'bigint') { 373 | throw new Error('input into Functions.encodeUint256 is not a number or bigint') 374 | } 375 | if (typeof num === 'number') { 376 | if (!Number.isInteger(num)) { 377 | throw new Error('input into Functions.encodeUint256 is not an integer') 378 | } 379 | } 380 | num = BigInt(num) 381 | if (num < 0) { 382 | throw new Error('input into Functions.encodeUint256 is negative') 383 | } 384 | if (num > 2n ** 256n - 1n) { 385 | throw new Error('input into Functions.encodeUint256 is too large') 386 | } 387 | 388 | let hexStr = num.toString(16) // Convert to hexadecimal 389 | hexStr = hexStr.padStart(64, '0') // Pad with leading zeros 390 | if (hexStr.length > 64) { 391 | throw new Error('input is too large') 392 | } 393 | const arr = new Uint8Array(32) 394 | for (let i = 0; i < arr.length; i++) { 395 | arr[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16) 396 | } 397 | return arr 398 | }, 399 | 400 | encodeInt256: (num: bigint | number): Uint8Array => { 401 | if (typeof num !== 'number' && typeof num !== 'bigint') { 402 | throw new Error('input into Functions.encodeInt256 is not a number or bigint') 403 | } 404 | if (typeof num === 'number') { 405 | if (!Number.isInteger(num)) { 406 | throw new Error('input into Functions.encodeInt256 is not an integer') 407 | } 408 | } 409 | num = BigInt(num) 410 | if (num < -(2n ** 255n)) { 411 | throw new Error('input into Functions.encodeInt256 is too small') 412 | } 413 | if (num > 2n ** 255n - 1n) { 414 | throw new Error('input into Functions.encodeInt256 is too large') 415 | } 416 | 417 | let hexStr 418 | if (num >= BigInt(0)) { 419 | hexStr = num.toString(16) // Convert to hexadecimal 420 | } else { 421 | // Calculate two's complement for negative numbers 422 | const absVal = -num 423 | let binStr = absVal.toString(2) // Convert to binary 424 | binStr = binStr.padStart(256, '0') // Pad to 256 bits 425 | // Invert bits 426 | let invertedBinStr = '' 427 | for (const bit of binStr) { 428 | invertedBinStr += bit === '0' ? '1' : '0' 429 | } 430 | // Add one 431 | let invertedBigInt = BigInt('0b' + invertedBinStr) 432 | invertedBigInt += 1n 433 | hexStr = invertedBigInt.toString(16) // Convert to hexadecimal 434 | } 435 | hexStr = hexStr.padStart(64, '0') // Pad with leading zeros 436 | if (hexStr.length > 64) { 437 | throw new Error('input is too large') 438 | } 439 | const arr = new Uint8Array(32) 440 | for (let i = 0; i < arr.length; i++) { 441 | arr[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16) 442 | } 443 | return arr 444 | }, 445 | 446 | encodeString: (str: string): Uint8Array => { 447 | const encoder = new TextEncoder() 448 | return encoder.encode(str) 449 | }, 450 | } 451 | 452 | try { 453 | const userScript = (async () => { 454 | //INJECT_USER_CODE_HERE 455 | }) as () => Promise 456 | const result = await userScript() 457 | 458 | if (!(result instanceof ArrayBuffer) && !(result instanceof Uint8Array)) { 459 | throw Error('returned value not an ArrayBuffer or Uint8Array') 460 | } 461 | 462 | const arrayBufferToHex = (input: ArrayBuffer | Uint8Array): string => { 463 | let hex = '' 464 | const uInt8Array = new Uint8Array(input) 465 | 466 | uInt8Array.forEach(byte => { 467 | hex += byte.toString(16).padStart(2, '0') 468 | }) 469 | 470 | return '0x' + hex 471 | } 472 | 473 | console.log( 474 | '\n' + 475 | JSON.stringify({ 476 | success: arrayBufferToHex(result), 477 | }), 478 | ) 479 | } catch (e: unknown) { 480 | let error: Error 481 | if (e instanceof Error) { 482 | error = e 483 | } else if (typeof e === 'string') { 484 | error = new Error(e) 485 | } else { 486 | error = new Error(`invalid value thrown of type ${typeof e}`) 487 | } 488 | 489 | console.log( 490 | '\n' + 491 | JSON.stringify({ 492 | error: { 493 | name: error?.name ?? 'Error', 494 | message: error?.message ?? 'invalid value returned', 495 | details: error?.stack ?? undefined, 496 | }, 497 | }), 498 | ) 499 | } finally { 500 | __2___.close() 501 | } 502 | -------------------------------------------------------------------------------- /src/simulateScript/frontendAllowedModules.ts: -------------------------------------------------------------------------------- 1 | export type AllowedModules = 'buffer' | 'crypto' | 'querystring' | 'string_decoder' | 'url' | 'util' 2 | 3 | const exhaustiveCheck = (module: never): never => { 4 | throw Error(`Import of module ${module} not allowed`) 5 | } 6 | 7 | export const safeRequire = (module: AllowedModules): void => { 8 | switch (module) { 9 | case 'buffer': 10 | return require('buffer') 11 | case 'crypto': 12 | return require('crypto') 13 | case 'querystring': 14 | return require('querystring') 15 | case 'string_decoder': 16 | return require('string_decoder') 17 | case 'url': 18 | return require('url') 19 | case 'util': 20 | return require('util') 21 | default: 22 | exhaustiveCheck(module) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/simulateScript/frontendSimulateScript.ts: -------------------------------------------------------------------------------- 1 | import vm from 'vm' 2 | import { FunctionsModule } from './Functions' 3 | import { safeRequire } from './frontendAllowedModules' 4 | 5 | type Hexstring = `0x${string}` 6 | 7 | interface SimulationParams { 8 | source: string 9 | secrets?: Record 10 | args?: string[] 11 | maxHttpRequests?: number 12 | maxResponseBytes?: number 13 | maxExecutionDurationMs?: number 14 | } 15 | 16 | const DEFAULT_MAX_HTTP_REQUESTS = 5 17 | const DEFAULT_MAX_RESPONSE_BYTES = 256 18 | const DEFAULT_MAX_EXECUTION_DURATION_MS = 10_000 19 | const allowedGlobalObjectsAndFunctions = { 20 | Buffer, 21 | URL, 22 | Date, 23 | Object, 24 | Array, 25 | Function, 26 | String, 27 | Number, 28 | Boolean, 29 | RegExp, 30 | Math, 31 | JSON, 32 | Promise, 33 | Map, 34 | Set, 35 | WeakMap, 36 | WeakSet, 37 | Proxy, 38 | Reflect, 39 | Symbol, 40 | BigInt, 41 | } 42 | 43 | // This function has been deprecated, but is currently used by the Functions Playground frontend 44 | export const simulateScript = async ({ 45 | source, 46 | secrets, 47 | args, 48 | maxHttpRequests = DEFAULT_MAX_HTTP_REQUESTS, 49 | maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, 50 | maxExecutionDurationMs = DEFAULT_MAX_EXECUTION_DURATION_MS, 51 | }: SimulationParams): Promise<{ result?: Hexstring; error?: Error; capturedStdout?: string }> => { 52 | try { 53 | validateInput({ source, args, secrets }) 54 | } catch (error) { 55 | return { 56 | error: Error(`${error}`), 57 | } 58 | } 59 | 60 | const functionsModule = new FunctionsModule() 61 | const Functions = functionsModule.buildFunctionsmodule(maxHttpRequests) 62 | 63 | let capturedStdout = '' 64 | 65 | const sandbox = { 66 | args, 67 | secrets, 68 | Functions, 69 | require: safeRequire, 70 | eval: () => { 71 | throw Error('eval not allowed') 72 | }, 73 | console: { 74 | ...console, 75 | log: (...args: unknown[]): void => { 76 | const message = args.map(arg => `${arg}`).join(' ') 77 | capturedStdout += message + '\n' 78 | }, 79 | }, 80 | ...allowedGlobalObjectsAndFunctions, 81 | } 82 | 83 | let result: Hexstring 84 | try { 85 | const startTime = Date.now() 86 | result = getValidOutput( 87 | await vm.runInNewContext(`(async () => {\n${source}\n})()`, sandbox), 88 | maxResponseBytes, 89 | ) 90 | const totalTime = Date.now() - startTime 91 | if (totalTime > maxExecutionDurationMs) { 92 | throw Error( 93 | `Execution time exceeded\nScript took ${totalTime}ms to complete but must be completed within ${maxExecutionDurationMs}ms`, 94 | ) 95 | } 96 | } catch (error) { 97 | return { error: Error(`${error}`), capturedStdout } 98 | } 99 | 100 | return { 101 | result: result, 102 | capturedStdout, 103 | } 104 | } 105 | 106 | const validateInput = ({ 107 | secrets, 108 | args, 109 | source, 110 | }: { 111 | source: string 112 | secrets?: Record 113 | args?: string[] 114 | }): void => { 115 | if (typeof source !== 'string') { 116 | throw Error('Invalid source code') 117 | } 118 | 119 | if (args) { 120 | if (!Array.isArray(args)) { 121 | throw Error('Invalid args') 122 | } 123 | 124 | for (const arg of args) { 125 | if (typeof arg !== 'string') { 126 | throw Error('Invalid args') 127 | } 128 | } 129 | } 130 | 131 | if ( 132 | secrets && 133 | (typeof secrets !== 'object' || 134 | !Object.values(secrets).every(s => { 135 | return typeof s === 'string' 136 | })) 137 | ) { 138 | throw Error('secrets param not a string map') 139 | } 140 | } 141 | 142 | const getValidOutput = (sandboxOutput: unknown, maxResponseBytes: number): Hexstring => { 143 | if (Buffer.isBuffer(sandboxOutput)) { 144 | if (sandboxOutput.length > maxResponseBytes) { 145 | throw Error(`returned Buffer >${maxResponseBytes} bytes`) 146 | } 147 | if (sandboxOutput.length === 0) { 148 | return '0x0' 149 | } 150 | return `0x${sandboxOutput.toString('hex')}` 151 | } 152 | throw Error('returned value not a Buffer') 153 | } 154 | -------------------------------------------------------------------------------- /src/simulateScript/index.ts: -------------------------------------------------------------------------------- 1 | export * from './simulateScript' 2 | -------------------------------------------------------------------------------- /src/simulateScript/safePow.ts: -------------------------------------------------------------------------------- 1 | type SafePowParams = { 2 | base: bigint 3 | exponent: bigint 4 | } 5 | 6 | const getValidInput = (base: bigint | number, exponent: bigint | number): SafePowParams => { 7 | let validBase = BigInt(0) 8 | 9 | if (typeof base === 'number') { 10 | if (!Number.isInteger(base)) { 11 | throw Error('safePow invalid base') 12 | } 13 | 14 | validBase = BigInt(base) 15 | } else { 16 | validBase = base 17 | } 18 | 19 | let validExponent = BigInt(0) 20 | if (typeof exponent === 'number') { 21 | if (!Number.isInteger(exponent)) { 22 | throw Error('safePow invalid exponent') 23 | } 24 | 25 | validExponent = BigInt(exponent) 26 | } else { 27 | validExponent = exponent 28 | } 29 | 30 | if (validExponent < BigInt(0)) { 31 | throw Error('safePow invalid exponent (must be positive)') 32 | } 33 | 34 | return { base: validBase, exponent: validExponent } 35 | } 36 | 37 | const isOdd = (value: bigint): boolean => value % BigInt(2) === BigInt(1) 38 | 39 | /** 40 | * safePow performs exponentiation with BigInts in browser safe mode as opposed to: 41 | * - `Math.pow`, which doesn't currently support BigInts. 42 | * - `**` operator which might get transpiled to Math.pow by browser VM or build tools like swc. 43 | * 44 | * Method supports integer numbers and bigints. 45 | */ 46 | export const safePow = (base: bigint | number, exponent: bigint | number): bigint => { 47 | let result = BigInt(1) 48 | let { base: currentBase, exponent: currentExponent } = getValidInput(base, exponent) 49 | 50 | while (currentExponent > BigInt(0)) { 51 | if (isOdd(currentExponent)) { 52 | result *= currentBase 53 | } 54 | 55 | currentBase *= currentBase 56 | currentExponent /= BigInt(2) 57 | } 58 | 59 | return result 60 | } 61 | -------------------------------------------------------------------------------- /src/simulateScript/simulateScript.ts: -------------------------------------------------------------------------------- 1 | import { execSync, spawn } from 'child_process' 2 | import fs from 'fs' 3 | import os from 'os' 4 | import path from 'path' 5 | 6 | import { 7 | DEFAULT_MAX_ON_CHAIN_RESPONSE_BYTES, 8 | DEFAULT_MAX_EXECUTION_DURATION_MS, 9 | DEFAULT_MAX_HTTP_REQUEST_BYTES, 10 | DEFAULT_MAX_HTTP_REQUEST_DURATION_MS, 11 | DEFAULT_MAX_HTTP_REQUESTS, 12 | DEFAULT_MAX_HTTP_REQUEST_URL_LENGTH, 13 | DEFAULT_MAX_MEMORY_USAGE_MB, 14 | DEFAULT_MAX_HTTP_RESPONSE_BYTES, 15 | } from '../simulationConfig' 16 | 17 | import type { SimulationInput, SimulationResult } from '../types' 18 | 19 | type SandboxResult = { 20 | success?: string 21 | error?: { 22 | name: string 23 | message: string 24 | details?: string 25 | } 26 | } 27 | 28 | export const simulateScript = async ({ 29 | source, 30 | secrets, 31 | args, 32 | bytesArgs, 33 | maxOnChainResponseBytes = DEFAULT_MAX_ON_CHAIN_RESPONSE_BYTES, 34 | maxExecutionTimeMs = DEFAULT_MAX_EXECUTION_DURATION_MS, 35 | maxMemoryUsageMb = DEFAULT_MAX_MEMORY_USAGE_MB, 36 | numAllowedQueries = DEFAULT_MAX_HTTP_REQUESTS, 37 | maxQueryDurationMs = DEFAULT_MAX_HTTP_REQUEST_DURATION_MS, 38 | maxQueryUrlLength = DEFAULT_MAX_HTTP_REQUEST_URL_LENGTH, 39 | maxQueryRequestBytes = DEFAULT_MAX_HTTP_REQUEST_BYTES, 40 | maxQueryResponseBytes = DEFAULT_MAX_HTTP_RESPONSE_BYTES, 41 | }: SimulationInput): Promise => { 42 | if (typeof source !== 'string') { 43 | throw Error('source param is missing or invalid') 44 | } 45 | if ( 46 | secrets && 47 | (typeof secrets !== 'object' || 48 | !Object.values(secrets).every(s => { 49 | return typeof s === 'string' 50 | })) 51 | ) { 52 | throw Error('secrets param not a string map') 53 | } 54 | if (args) { 55 | if (!Array.isArray(args)) { 56 | throw Error('args param not an array') 57 | } 58 | for (const arg of args as string[]) { 59 | if (typeof arg !== 'string') { 60 | throw Error('args param not a string array') 61 | } 62 | } 63 | } 64 | if (bytesArgs) { 65 | if (!Array.isArray(bytesArgs)) { 66 | throw Error('bytesArgs param not an array') 67 | } 68 | for (const arg of bytesArgs as string[]) { 69 | if (typeof arg !== 'string' || !/^0x[0-9A-Fa-f]+$/.test(arg)) { 70 | throw Error('bytesArgs param contains invalid hex string') 71 | } 72 | } 73 | } 74 | 75 | // Check if deno is installed 76 | try { 77 | execSync('deno --version', { stdio: 'pipe' }) 78 | } catch { 79 | throw Error( 80 | 'Deno must be installed and accessible via the PATH environment variable (ie: the `deno --version` command must work).\nVisit https://deno.land/#installation for installation instructions.', 81 | ) 82 | } 83 | 84 | const scriptPath = createScriptTempFile(source) 85 | 86 | const simulation = spawn('deno', [ 87 | 'run', 88 | '--no-prompt', 89 | `--v8-flags=--max-old-space-size=${maxMemoryUsageMb}`, 90 | '--allow-net', 91 | scriptPath, 92 | Buffer.from(JSON.stringify(secrets ?? {})).toString('base64'), // Base64 encode stringified objects to avoid CLI parsing issues 93 | Buffer.from(JSON.stringify(args ?? {})).toString('base64'), 94 | Buffer.from(JSON.stringify(bytesArgs ?? {})).toString('base64'), 95 | numAllowedQueries.toString(), 96 | maxQueryDurationMs.toString(), 97 | maxQueryUrlLength.toString(), 98 | maxQueryRequestBytes.toString(), 99 | maxQueryResponseBytes.toString(), 100 | ]) 101 | 102 | const timeout = setTimeout(() => { 103 | simulation.kill('SIGKILL') 104 | }, maxExecutionTimeMs) 105 | 106 | const simulationComplete = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( 107 | resolve => { 108 | simulation.on('exit', (code, signal) => { 109 | clearTimeout(timeout) 110 | resolve({ code, signal }) 111 | }) 112 | }, 113 | ) 114 | 115 | let output = '' 116 | 117 | simulation.stdout.on('data', (data: Buffer) => { 118 | output += data.toString() 119 | }) 120 | 121 | simulation.stderr.on('data', (data: Buffer) => { 122 | output += data.toString() 123 | }) 124 | 125 | const { code, signal } = await simulationComplete 126 | 127 | try { 128 | fs.rmSync(scriptPath) 129 | } catch { 130 | // The temp file may have already been deleted 131 | } 132 | 133 | let capturedTerminalOutput: string 134 | let parsedOutput: SandboxResult = {} 135 | 136 | try { 137 | const outputArr = output.toString().split('\n') 138 | const finalLine = outputArr[outputArr.length - 2] 139 | parsedOutput = JSON.parse(finalLine!) as SandboxResult 140 | capturedTerminalOutput = outputArr.slice(0, -2).join('\n') 141 | } catch { 142 | capturedTerminalOutput = output 143 | } 144 | 145 | const simulationResult: SimulationResult = { 146 | capturedTerminalOutput, 147 | } 148 | 149 | if (parsedOutput.success) { 150 | simulationResult.responseBytesHexstring = parsedOutput.success 151 | if ((simulationResult.responseBytesHexstring.length - 2) / 2 > maxOnChainResponseBytes) { 152 | simulationResult.errorString = `response >${maxOnChainResponseBytes} bytes` 153 | delete simulationResult.responseBytesHexstring 154 | } 155 | return simulationResult 156 | } 157 | 158 | if (parsedOutput.error) { 159 | simulationResult.errorString = parsedOutput.error.message 160 | if (parsedOutput.error?.name === 'PermissionDenied') { 161 | simulationResult.errorString = 'attempted access to blocked resource detected' 162 | } 163 | return simulationResult 164 | } 165 | 166 | if (signal === 'SIGKILL') { 167 | simulationResult.errorString = 'script runtime exceeded' 168 | return simulationResult 169 | } 170 | 171 | if (code !== 0) { 172 | simulationResult.errorString = 'syntax error, RAM exceeded, or other error' 173 | return simulationResult 174 | } 175 | 176 | return simulationResult 177 | } 178 | 179 | const createScriptTempFile = (source: string): string => { 180 | const tmpDir = os.tmpdir() 181 | const sandboxText = fs.readFileSync(path.join(__dirname, '/deno-sandbox/sandbox.ts'), 'utf8') 182 | const script = sandboxText.replace('//INJECT_USER_CODE_HERE', source) 183 | const tmpFilePath = path.join(tmpDir, `FunctionsScript-${Date.now()}.ts`) 184 | fs.writeFileSync(tmpFilePath, script) 185 | return tmpFilePath 186 | } 187 | -------------------------------------------------------------------------------- /src/simulationConfig.ts: -------------------------------------------------------------------------------- 1 | export const simulatedLinkEthPrice = BigInt('6000000000000000') 2 | export const simulatedLinkUsdPrice = BigInt('1500000000') 3 | 4 | export const simulatedDonId = 'local-functions-testnet' 5 | 6 | export const simulatedAllowListId = 'allowlist' 7 | 8 | export const simulatedRouterConfig = { 9 | maxConsumersPerSubscription: 100, 10 | adminFee: 0, 11 | handleOracleFulfillmentSelector: '0x0ca76175', // handleOracleFulfillment(bytes32 requestId, bytes memory response, bytes memory err) 12 | gasForCallExactCheck: 5000, 13 | maxCallbackGasLimits: [300_000, 500_000, 1_000_000], 14 | subscriptionDepositMinimumRequests: 0, 15 | subscriptionDepositJuels: 0, 16 | } 17 | 18 | export const simulatedCoordinatorConfig = { 19 | maxCallbackGasLimit: 1_000_000, 20 | feedStalenessSeconds: 86_400, 21 | gasOverheadBeforeCallback: 44_615, 22 | gasOverheadAfterCallback: 44_615, 23 | requestTimeoutSeconds: 0, // 300 is used on actual mainnet & testnet blockchains 24 | donFeeCentsUsd: 0, 25 | maxSupportedRequestDataVersion: 1, 26 | fulfillmentGasPriceOverEstimationBP: 0, 27 | fallbackNativePerUnitLink: BigInt('5000000000000000'), 28 | minimumEstimateGasPriceWei: 1000000000, // 1 gwei 29 | fallbackUsdPerUnitLink: 1400000000, 30 | fallbackUsdPerUnitLinkDecimals: 8, 31 | operationFeeCentsUsd: 0, 32 | } 33 | 34 | export const simulatedAllowListConfig = { 35 | enabled: false, 36 | signerPublicKey: '0x0000000000000000000000000000000000000000', 37 | } 38 | 39 | export const callReportGasLimit = 5_000_000 40 | 41 | export const numberOfSimulatedNodeExecutions = 4 42 | 43 | export const simulatedWallets = { 44 | node0: { 45 | address: '0xAe24F6e7e046a0C764DF51F333dE5e2fE360AC72', 46 | privateKey: '0x493f20c367e9c5190b14b8071a6c765da973d41428b841c25e4aaba3577f8ece', 47 | }, 48 | node1: { 49 | address: '0x37d7bf16f6fd8c37b766Fa87e047c68c51dfdf4a', 50 | privateKey: '0x7abd90843922984dda18358a179679e5cabda5ad8d0ebab5714ac044663a6a14', 51 | }, 52 | node2: { 53 | address: '0x6e7EF53D9811B70834902D2D9137DaD2720eAC47', 54 | privateKey: '0xcb8801121add786869aac78ceb4003bf3aa8a68ae8dd31f80d61f5f98eace3c5', 55 | }, 56 | node3: { 57 | address: '0xBe83eA9868AE964f8C46EFa0fea798EbE16441c5', 58 | privateKey: '0x06c7ca21f24edf450251e87097264b1fd184c9570084a78aa3300e937e1954b8', 59 | }, 60 | } 61 | 62 | export const simulatedTransmitters = Object.values(simulatedWallets).map(wallet => wallet.address) 63 | 64 | export const simulatedSecretsKeys: { 65 | thresholdKeys: { 66 | publicKey: string 67 | privateKeyShares: { 68 | [address: string]: string 69 | } 70 | } 71 | donKey: { 72 | publicKey: string 73 | privateKey: string 74 | } 75 | } = { 76 | thresholdKeys: { 77 | publicKey: 78 | '{"Group":"P256","G_bar":"BLCl28PjjGt8JyL/p6AHToD6265gEBfl12mBiCVZShSPHVwvx5GwJ0QMqpQ7yPZEM8E6U015XFHvsDuq8X/S/c8=","H":"BEDshIeMEgr2kjNdjkG12M0A9P0uwg5Hl7jbKjbIcweHi07tu8rITgMZ9dTfqLhtFu+cRwwZaLLZdhqdg1JyLYY=","HArray":["BCj9afGghnfy3Nubj7onMPkApbF9r4GbLvSSi1wrQ1uMwRYMr6DCt5RCm95vKx75JPuOFdKBkBTOpX4p5Dtt0l0=","BJCmC0+jkl/WTK8sfb6ulQjBWTZnQEasPRVdCIYv94RkZWfVk6CbFS2Dv9C090He4UaYBaOGGyw7HGAtqKUqX1Y=","BPPnFxrq+9VI8Bb6KUBJalt/EZdU+G/l4iyosvB5bulwWDxJ26mw3hJZtZfjUcJPGIajabNFOa+5pVBd6Y3oGB8=","BJ1tWD2RhKB/uQEJ1x54mBddAW0KoFghplSswp/F3BYksyZIRIhEiLDsNgw3NfhmQh2OR6Vgv4APqAt9+RKxzzk="]}', 79 | privateKeyShares: { 80 | '0xAe24F6e7e046a0C764DF51F333dE5e2fE360AC72': 81 | '{"Group":"P256","Index":0,"V":"XuDZcsMc5ebjgbHx+zQ/Hhbwn24MgJ5oBL+ORQGqM8c="}', 82 | '0x37d7bf16f6fd8c37b766Fa87e047c68c51dfdf4a': 83 | '{"Group":"P256","Index":1,"V":"x3UbVxPoPQvRTL6ILjuBSGep3UUPY2q7j6LjHR2tU2A="}', 84 | '0x6e7EF53D9811B70834902D2D9137DaD2720eAC47': 85 | '{"Group":"P256","Index":2,"V":"MAldPGSzlC+/F8seYULDcvt8IG5rLpiKJsxtMj1NTag="}', 86 | '0xBe83eA9868AE964f8C46EFa0fea798EbE16441c5': 87 | '{"Group":"P256","Index":3,"V":"mJ2fILV+61Ss4te0lEoFnUw1XkVuEWTdsa/CCllQbUE="}', 88 | }, 89 | }, 90 | donKey: { 91 | publicKey: 92 | '0x46e62235e8ac8a4f84aa62baf7c67d73a23c5641821bab8d24a161071b90ed8295195d81ba34e4492f773c84e63617879c99480a7d9545385b56b5fdfd88d0da', 93 | privateKey: '0x32d6fac6ddc22adc2144aa75de175556c0095b795cb1bc7b2a53c8a07462e8e3', 94 | }, 95 | } 96 | 97 | export const DEFAULT_MAX_ON_CHAIN_RESPONSE_BYTES = 256 98 | export const DEFAULT_MAX_EXECUTION_DURATION_MS = 10_000 // 10 seconds 99 | export const DEFAULT_MAX_MEMORY_USAGE_MB = 128 100 | export const DEFAULT_MAX_HTTP_REQUESTS = 5 101 | export const DEFAULT_MAX_HTTP_REQUEST_DURATION_MS = 9_000 // 9 seconds 102 | export const DEFAULT_MAX_HTTP_REQUEST_URL_LENGTH = 2048 // 2 KB 103 | export const DEFAULT_MAX_HTTP_REQUEST_BYTES = 1024 * 30 // 30 KB 104 | export const DEFAULT_MAX_HTTP_RESPONSE_BYTES = 2_097_152 // 2 MB 105 | -------------------------------------------------------------------------------- /src/tdh2.js: -------------------------------------------------------------------------------- 1 | const rnd = require('bcrypto/lib/random') 2 | const sha256 = require('bcrypto/lib/sha256') 3 | const elliptic = require('bcrypto/lib/js/elliptic') 4 | const cipher = require('bcrypto/lib/cipher') 5 | 6 | const { curves } = elliptic 7 | 8 | const { Cipher } = cipher 9 | 10 | const p256 = new curves.P256() 11 | const groupName = 'P256' 12 | const tdh2InputSize = 32 13 | 14 | function toHexString(byteArray) { 15 | return Array.from(byteArray, function (byte) { 16 | return ('0' + (byte & 0xff).toString(16)).slice(-2) 17 | }).join('') 18 | } 19 | 20 | function tdh2Encrypt(pub, msg, label) { 21 | if (pub.Group != groupName) throw Error('invalid group') 22 | const g_bar = p256.decodePoint(Buffer.from(pub.G_bar, 'base64')) 23 | const h = p256.decodePoint(Buffer.from(pub.H, 'base64')) 24 | 25 | const r = p256.randomScalar(rnd) 26 | const s = p256.randomScalar(rnd) 27 | 28 | const c = xor(hash1(h.mul(r)), msg) 29 | 30 | const u = p256.g.mul(r) 31 | const w = p256.g.mul(s) 32 | const uBar = g_bar.mul(r) 33 | const wBar = g_bar.mul(s) 34 | 35 | const e = hash2(c, label, u, w, uBar, wBar) 36 | const f = s.add(r.mul(e).mod(p256.n)).mod(p256.n) 37 | 38 | return JSON.stringify({ 39 | Group: groupName, 40 | C: c.toString('base64'), 41 | Label: label.toString('base64'), 42 | U: p256.encodePoint(u, false).toString('base64'), 43 | U_bar: p256.encodePoint(uBar, false).toString('base64'), 44 | E: p256.encodeScalar(e).toString('base64'), 45 | F: p256.encodeScalar(f).toString('base64'), 46 | }) 47 | } 48 | 49 | function concatenate(points) { 50 | var out = groupName 51 | for (let i = 0; i < points.length; i++) { 52 | out += ',' + toHexString(p256.encodePoint(points[i], false)) 53 | } 54 | 55 | return Buffer.from(out) 56 | } 57 | 58 | function hash1(point) { 59 | return sha256.digest(Buffer.concat([Buffer.from('tdh2hash1'), concatenate([point])])) 60 | } 61 | 62 | function hash2(msg, label, p1, p2, p3, p4) { 63 | if (msg.length != tdh2InputSize) throw new Error('message has incorrect length') 64 | 65 | if (label.length != tdh2InputSize) throw new Error('label has incorrect length') 66 | 67 | const h = sha256.digest( 68 | Buffer.concat([Buffer.from('tdh2hash2'), msg, label, concatenate([p1, p2, p3, p4])]), 69 | ) 70 | 71 | return p256.decodeScalar(h) 72 | } 73 | 74 | function xor(a, b) { 75 | if (a.length != b.length) throw new Error('buffers with different lengths') 76 | 77 | var out = Buffer.alloc(a.length) 78 | for (var i = 0; i < a.length; i++) { 79 | out[i] = a[i] ^ b[i] 80 | } 81 | 82 | return out 83 | } 84 | 85 | function encrypt(pub, msg) { 86 | const ciph = new Cipher('AES-256-GCM') 87 | const key = rnd.randomBytes(tdh2InputSize) 88 | const nonce = rnd.randomBytes(12) 89 | 90 | ciph.init(key, nonce) 91 | const ctxt = Buffer.concat([ciph.update(msg), ciph.final(), ciph.getAuthTag()]) 92 | 93 | const tdh2Ctxt = tdh2Encrypt(pub, key, Buffer.alloc(tdh2InputSize)) 94 | 95 | return JSON.stringify({ 96 | TDH2Ctxt: Buffer.from(tdh2Ctxt).toString('base64'), 97 | SymCtxt: ctxt.toString('base64'), 98 | Nonce: nonce.toString('base64'), 99 | }) 100 | } 101 | 102 | module.exports = { encrypt } 103 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Overrides, Contract, providers } from 'ethers' 2 | import { Anvil } from '@viem/anvil' 3 | 4 | export enum Location { 5 | Inline = 0, 6 | Remote = 1, 7 | DONHosted = 2, 8 | } 9 | 10 | export enum CodeLanguage { 11 | JavaScript = 0, 12 | } 13 | 14 | export enum ReturnType { 15 | uint = 'uint256', 16 | // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values 17 | uint256 = 'uint256', 18 | int = 'int256', 19 | // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values 20 | int256 = 'int256', 21 | string = 'string', 22 | bytes = 'bytes', 23 | } 24 | 25 | export type FunctionsRequestParams = { 26 | codeLocation: Location 27 | secretsLocation?: Location 28 | codeLanguage: CodeLanguage 29 | source: string 30 | encryptedSecretsReference?: string 31 | args?: string[] 32 | bytesArgs?: string[] 33 | } 34 | 35 | export type ThresholdPublicKey = { 36 | Group: string 37 | G_bar: string 38 | H: string 39 | HArray: string[] 40 | } 41 | 42 | export type RequestCommitmentFetchConfig = { 43 | requestId: string 44 | provider: providers.JsonRpcProvider 45 | functionsRouterAddress: string 46 | donId: string 47 | toBlock?: number | 'latest' // Ending block number to search for the request commitment 48 | pastBlocksToSearch?: number // Number of blocks from the ending block to search for the request commitment (searches from toBlock-pastBlocksToSearch to toBlock) 49 | } 50 | 51 | export type TransactionOptions = { 52 | overrides?: Overrides 53 | confirmations?: number 54 | } 55 | 56 | export type SubCreateConfig = { 57 | consumerAddress?: string 58 | txOptions?: TransactionOptions 59 | } 60 | 61 | export type SubConsumerConfig = { 62 | subscriptionId: bigint | number | string 63 | consumerAddress: string 64 | txOptions?: TransactionOptions 65 | } 66 | 67 | export type SubFundConfig = { 68 | juelsAmount: bigint | string 69 | subscriptionId: bigint | number | string 70 | txOptions?: TransactionOptions 71 | } 72 | 73 | export type SubCancelConfig = { 74 | subscriptionId: bigint | number | string 75 | refundAddress?: string 76 | txOptions?: TransactionOptions 77 | } 78 | export type SubTransferConfig = { 79 | subscriptionId: bigint | number | string 80 | newOwner: string 81 | txOptions?: TransactionOptions 82 | } 83 | 84 | export type SubTransferAcceptConfig = { 85 | subscriptionId: bigint | number | string 86 | txOptions?: TransactionOptions 87 | } 88 | 89 | export type SubTimeoutConfig = { 90 | requestCommitments: RequestCommitment[] 91 | txOptions?: TransactionOptions 92 | } 93 | 94 | export type EstimateCostConfig = { 95 | donId: string 96 | subscriptionId: bigint | number | string 97 | callbackGasLimit: number 98 | gasPriceWei: bigint 99 | } 100 | 101 | export type SubscriptionInfo = { 102 | balance: bigint 103 | owner: string 104 | blockedBalance: bigint 105 | proposedOwner: string 106 | consumers: string[] 107 | flags: string 108 | } 109 | 110 | export type RequestCommitment = { 111 | requestId: string 112 | coordinator: string 113 | estimatedTotalCostJuels: bigint 114 | client: string 115 | subscriptionId: number 116 | callbackGasLimit: bigint 117 | adminFee: bigint 118 | donFee: bigint 119 | gasOverheadBeforeCallback: bigint 120 | gasOverheadAfterCallback: bigint 121 | timeoutTimestamp: bigint 122 | } 123 | 124 | export type DONStoragePayload = { 125 | slot_id: number 126 | version: number 127 | payload: string // base64 encrypted secrets 128 | expiration: number 129 | signature: string // base64 130 | } 131 | 132 | export type GatewayMessageConfig = { 133 | gatewayUrls: string[] 134 | method: string 135 | don_id: string 136 | payload?: DONStoragePayload 137 | } 138 | 139 | export type GatewayMessageBody = { 140 | message_id: string 141 | method: string 142 | don_id: string 143 | receiver: string 144 | payload?: DONStoragePayload 145 | } 146 | 147 | export type GatewayMessage = { 148 | id: string 149 | jsonrpc: '2.0' 150 | method: string 151 | params: { 152 | body: GatewayMessageBody 153 | signature: string 154 | } 155 | } 156 | 157 | type EncryptedSecretsEntry = { 158 | slot_id: number 159 | version: number 160 | expiration: number 161 | } 162 | 163 | export type NodeResponse = { 164 | success: boolean 165 | rows?: EncryptedSecretsEntry[] 166 | } 167 | 168 | export type GatewayResponse = { 169 | gatewayUrl: string 170 | nodeResponses: NodeResponse[] 171 | } 172 | 173 | export enum FulfillmentCode { 174 | FULFILLED = 0, // Indicates that calling the consumer contract's handleOracleFulfill method was successful 175 | USER_CALLBACK_ERROR = 1, // Indicates that the consumer contract's handleOracleFulfill method reverted 176 | INVALID_REQUEST_ID = 2, // Indicates a duplicate response to a request. This is not an error, but the response is ignored 177 | COST_EXCEEDS_COMMITMENT = 3, // Indicates that the request was not fulfilled because the cost of fulfillment is higher than the estimated cost due to an increase in gas prices 178 | INSUFFICIENT_GAS_PROVIDED = 4, // Internal error 179 | SUBSCRIPTION_BALANCE_INVARIANT_VIOLATION = 5, // Internal error 180 | INVALID_COMMITMENT = 6, // Internal error 181 | } 182 | 183 | export type FunctionsResponse = { 184 | requestId: string // Request ID of the fulfilled request represented as a bytes32 hex string 185 | subscriptionId: number // Subscription ID billed for request 186 | totalCostInJuels: bigint // Actual cost of request in Juels (1,000,000,000,000,000,000 (1e18) Juels are equal to 1 LINK) 187 | responseBytesHexstring: string // Response bytes sent to client contract represented as a hex string ("0x" if no response) 188 | errorString: string // Error bytes sent to client contract interpreted as a UTF-8 string ("" if no error) 189 | returnDataBytesHexstring: string // Data returned by consumer contract's handleOracleFulfillment method represented as a hex string 190 | fulfillmentCode: FulfillmentCode // Indicates whether the request was fulfilled successfully or not 191 | } 192 | 193 | export interface SimulationInput { 194 | source: string 195 | args?: string[] 196 | bytesArgs?: string[] 197 | secrets?: Record 198 | maxOnChainResponseBytes?: number 199 | maxExecutionTimeMs?: number 200 | maxMemoryUsageMb?: number 201 | numAllowedQueries?: number 202 | maxQueryDurationMs?: number 203 | maxQueryUrlLength?: number 204 | maxQueryRequestBytes?: number 205 | maxQueryResponseBytes?: number 206 | } 207 | 208 | export type SimulationResult = { 209 | responseBytesHexstring?: string 210 | errorString?: string 211 | capturedTerminalOutput: string 212 | } 213 | 214 | export interface RequestEventData { 215 | requestId: string 216 | requestingContract: string 217 | requestInitiator: string 218 | subscriptionId: number 219 | subscriptionOwner: string 220 | data: string 221 | dataVersion: number 222 | flags: string 223 | callbackGasLimit: number 224 | commitment: RequestCommitment 225 | } 226 | 227 | export interface FunctionsContracts { 228 | donId: string 229 | linkTokenContract: Contract 230 | functionsRouterContract: Contract 231 | functionsMockCoordinatorContract: Contract 232 | } 233 | 234 | export type GetFunds = ( 235 | address: string, 236 | { weiAmount, juelsAmount }: { weiAmount?: bigint | string; juelsAmount?: bigint | string }, 237 | ) => Promise 238 | 239 | export type LocalFunctionsTestnet = { 240 | anvil: Anvil 241 | adminWallet: { 242 | address: string 243 | privateKey: string 244 | } 245 | getFunds: GetFunds 246 | close: () => Promise 247 | } & FunctionsContracts 248 | -------------------------------------------------------------------------------- /src/v1_contract_sources/MockV3Aggregator.ts: -------------------------------------------------------------------------------- 1 | export const MockV3AggregatorSource = { 2 | _format: 'hh-sol-artifact-1', 3 | contractName: 'MockV3Aggregator', 4 | sourceName: 'MockV3Aggregator.sol', 5 | abi: [ 6 | { 7 | inputs: [ 8 | { 9 | internalType: 'uint8', 10 | name: '_decimals', 11 | type: 'uint8', 12 | }, 13 | { 14 | internalType: 'int256', 15 | name: '_initialAnswer', 16 | type: 'int256', 17 | }, 18 | ], 19 | stateMutability: 'nonpayable', 20 | type: 'constructor', 21 | }, 22 | { 23 | anonymous: false, 24 | inputs: [ 25 | { 26 | indexed: true, 27 | internalType: 'int256', 28 | name: 'current', 29 | type: 'int256', 30 | }, 31 | { 32 | indexed: true, 33 | internalType: 'uint256', 34 | name: 'roundId', 35 | type: 'uint256', 36 | }, 37 | { 38 | indexed: false, 39 | internalType: 'uint256', 40 | name: 'updatedAt', 41 | type: 'uint256', 42 | }, 43 | ], 44 | name: 'AnswerUpdated', 45 | type: 'event', 46 | }, 47 | { 48 | anonymous: false, 49 | inputs: [ 50 | { 51 | indexed: true, 52 | internalType: 'uint256', 53 | name: 'roundId', 54 | type: 'uint256', 55 | }, 56 | { 57 | indexed: true, 58 | internalType: 'address', 59 | name: 'startedBy', 60 | type: 'address', 61 | }, 62 | { 63 | indexed: false, 64 | internalType: 'uint256', 65 | name: 'startedAt', 66 | type: 'uint256', 67 | }, 68 | ], 69 | name: 'NewRound', 70 | type: 'event', 71 | }, 72 | { 73 | inputs: [], 74 | name: 'decimals', 75 | outputs: [ 76 | { 77 | internalType: 'uint8', 78 | name: '', 79 | type: 'uint8', 80 | }, 81 | ], 82 | stateMutability: 'view', 83 | type: 'function', 84 | }, 85 | { 86 | inputs: [], 87 | name: 'description', 88 | outputs: [ 89 | { 90 | internalType: 'string', 91 | name: '', 92 | type: 'string', 93 | }, 94 | ], 95 | stateMutability: 'pure', 96 | type: 'function', 97 | }, 98 | { 99 | inputs: [ 100 | { 101 | internalType: 'uint256', 102 | name: '', 103 | type: 'uint256', 104 | }, 105 | ], 106 | name: 'getAnswer', 107 | outputs: [ 108 | { 109 | internalType: 'int256', 110 | name: '', 111 | type: 'int256', 112 | }, 113 | ], 114 | stateMutability: 'view', 115 | type: 'function', 116 | }, 117 | { 118 | inputs: [ 119 | { 120 | internalType: 'uint80', 121 | name: '_roundId', 122 | type: 'uint80', 123 | }, 124 | ], 125 | name: 'getRoundData', 126 | outputs: [ 127 | { 128 | internalType: 'uint80', 129 | name: 'roundId', 130 | type: 'uint80', 131 | }, 132 | { 133 | internalType: 'int256', 134 | name: 'answer', 135 | type: 'int256', 136 | }, 137 | { 138 | internalType: 'uint256', 139 | name: 'startedAt', 140 | type: 'uint256', 141 | }, 142 | { 143 | internalType: 'uint256', 144 | name: 'updatedAt', 145 | type: 'uint256', 146 | }, 147 | { 148 | internalType: 'uint80', 149 | name: 'answeredInRound', 150 | type: 'uint80', 151 | }, 152 | ], 153 | stateMutability: 'view', 154 | type: 'function', 155 | }, 156 | { 157 | inputs: [ 158 | { 159 | internalType: 'uint256', 160 | name: '', 161 | type: 'uint256', 162 | }, 163 | ], 164 | name: 'getTimestamp', 165 | outputs: [ 166 | { 167 | internalType: 'uint256', 168 | name: '', 169 | type: 'uint256', 170 | }, 171 | ], 172 | stateMutability: 'view', 173 | type: 'function', 174 | }, 175 | { 176 | inputs: [], 177 | name: 'latestAnswer', 178 | outputs: [ 179 | { 180 | internalType: 'int256', 181 | name: '', 182 | type: 'int256', 183 | }, 184 | ], 185 | stateMutability: 'view', 186 | type: 'function', 187 | }, 188 | { 189 | inputs: [], 190 | name: 'latestRound', 191 | outputs: [ 192 | { 193 | internalType: 'uint256', 194 | name: '', 195 | type: 'uint256', 196 | }, 197 | ], 198 | stateMutability: 'view', 199 | type: 'function', 200 | }, 201 | { 202 | inputs: [], 203 | name: 'latestRoundData', 204 | outputs: [ 205 | { 206 | internalType: 'uint80', 207 | name: 'roundId', 208 | type: 'uint80', 209 | }, 210 | { 211 | internalType: 'int256', 212 | name: 'answer', 213 | type: 'int256', 214 | }, 215 | { 216 | internalType: 'uint256', 217 | name: 'startedAt', 218 | type: 'uint256', 219 | }, 220 | { 221 | internalType: 'uint256', 222 | name: 'updatedAt', 223 | type: 'uint256', 224 | }, 225 | { 226 | internalType: 'uint80', 227 | name: 'answeredInRound', 228 | type: 'uint80', 229 | }, 230 | ], 231 | stateMutability: 'view', 232 | type: 'function', 233 | }, 234 | { 235 | inputs: [], 236 | name: 'latestTimestamp', 237 | outputs: [ 238 | { 239 | internalType: 'uint256', 240 | name: '', 241 | type: 'uint256', 242 | }, 243 | ], 244 | stateMutability: 'view', 245 | type: 'function', 246 | }, 247 | { 248 | inputs: [ 249 | { 250 | internalType: 'int256', 251 | name: '_answer', 252 | type: 'int256', 253 | }, 254 | ], 255 | name: 'updateAnswer', 256 | outputs: [], 257 | stateMutability: 'nonpayable', 258 | type: 'function', 259 | }, 260 | { 261 | inputs: [ 262 | { 263 | internalType: 'uint80', 264 | name: '_roundId', 265 | type: 'uint80', 266 | }, 267 | { 268 | internalType: 'int256', 269 | name: '_answer', 270 | type: 'int256', 271 | }, 272 | { 273 | internalType: 'uint256', 274 | name: '_timestamp', 275 | type: 'uint256', 276 | }, 277 | { 278 | internalType: 'uint256', 279 | name: '_startedAt', 280 | type: 'uint256', 281 | }, 282 | ], 283 | name: 'updateRoundData', 284 | outputs: [], 285 | stateMutability: 'nonpayable', 286 | type: 'function', 287 | }, 288 | { 289 | inputs: [], 290 | name: 'version', 291 | outputs: [ 292 | { 293 | internalType: 'uint256', 294 | name: '', 295 | type: 'uint256', 296 | }, 297 | ], 298 | stateMutability: 'view', 299 | type: 'function', 300 | }, 301 | ], 302 | bytecode: 303 | '0x608060405234801561001057600080fd5b506040516105113803806105118339818101604052604081101561003357600080fd5b5080516020909101516000805460ff191660ff84161790556100548161005b565b50506100a2565b600181815542600281905560038054909201808355600090815260046020908152604080832095909555835482526005815284822083905592548152600690925291902055565b610460806100b16000396000f3fe608060405234801561001057600080fd5b50600436106100d45760003560e01c80638205bf6a11610081578063b5ab58dc1161005b578063b5ab58dc14610273578063b633620c14610290578063feaf968c146102ad576100d4565b80638205bf6a146101db5780639a6fc8f5146101e3578063a87a20ce14610256576100d4565b806354fd4d50116100b257806354fd4d501461014e578063668a0f02146101565780637284e4161461015e576100d4565b8063313ce567146100d95780634aa2011f146100f757806350d25bcd14610134575b600080fd5b6100e16102b5565b6040805160ff9092168252519081900360200190f35b6101326004803603608081101561010d57600080fd5b5069ffffffffffffffffffff81351690602081013590604081013590606001356102be565b005b61013c61030b565b60408051918252519081900360200190f35b61013c610311565b61013c610316565b61016661031c565b6040805160208082528351818301528351919283929083019185019080838360005b838110156101a0578181015183820152602001610188565b50505050905090810190601f1680156101cd5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b61013c610353565b61020c600480360360208110156101f957600080fd5b503569ffffffffffffffffffff16610359565b604051808669ffffffffffffffffffff1681526020018581526020018481526020018381526020018269ffffffffffffffffffff1681526020019550505050505060405180910390f35b6101326004803603602081101561026c57600080fd5b5035610392565b61013c6004803603602081101561028957600080fd5b50356103d9565b61013c600480360360208110156102a657600080fd5b50356103eb565b61020c6103fd565b60005460ff1681565b69ffffffffffffffffffff90931660038181556001849055600283905560009182526004602090815260408084209590955581548352600581528483209390935554815260069091522055565b60015481565b600081565b60035481565b60408051808201909152601f81527f76302e362f74657374732f4d6f636b563341676772656761746f722e736f6c00602082015290565b60025481565b69ffffffffffffffffffff8116600090815260046020908152604080832054600683528184205460059093529220549293919290918490565b600181815542600281905560038054909201808355600090815260046020908152604080832095909555835482526005815284822083905592548152600690925291902055565b60046020526000908152604090205481565b60056020526000908152604090205481565b6003546000818152600460209081526040808320546006835281842054600590935292205483909192939456fea2646970667358221220322084388f0143aa385982691acf5098e574be38d1e32ad302b12740d00ea1fe64736f6c63430007000033', 304 | deployedBytecode: 305 | '0x608060405234801561001057600080fd5b50600436106100d45760003560e01c80638205bf6a11610081578063b5ab58dc1161005b578063b5ab58dc14610273578063b633620c14610290578063feaf968c146102ad576100d4565b80638205bf6a146101db5780639a6fc8f5146101e3578063a87a20ce14610256576100d4565b806354fd4d50116100b257806354fd4d501461014e578063668a0f02146101565780637284e4161461015e576100d4565b8063313ce567146100d95780634aa2011f146100f757806350d25bcd14610134575b600080fd5b6100e16102b5565b6040805160ff9092168252519081900360200190f35b6101326004803603608081101561010d57600080fd5b5069ffffffffffffffffffff81351690602081013590604081013590606001356102be565b005b61013c61030b565b60408051918252519081900360200190f35b61013c610311565b61013c610316565b61016661031c565b6040805160208082528351818301528351919283929083019185019080838360005b838110156101a0578181015183820152602001610188565b50505050905090810190601f1680156101cd5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b61013c610353565b61020c600480360360208110156101f957600080fd5b503569ffffffffffffffffffff16610359565b604051808669ffffffffffffffffffff1681526020018581526020018481526020018381526020018269ffffffffffffffffffff1681526020019550505050505060405180910390f35b6101326004803603602081101561026c57600080fd5b5035610392565b61013c6004803603602081101561028957600080fd5b50356103d9565b61013c600480360360208110156102a657600080fd5b50356103eb565b61020c6103fd565b60005460ff1681565b69ffffffffffffffffffff90931660038181556001849055600283905560009182526004602090815260408084209590955581548352600581528483209390935554815260069091522055565b60015481565b600081565b60035481565b60408051808201909152601f81527f76302e362f74657374732f4d6f636b563341676772656761746f722e736f6c00602082015290565b60025481565b69ffffffffffffffffffff8116600090815260046020908152604080832054600683528184205460059093529220549293919290918490565b600181815542600281905560038054909201808355600090815260046020908152604080832095909555835482526005815284822083905592548152600690925291902055565b60046020526000908152604090205481565b60056020526000908152604090205481565b6003546000818152600460209081526040808320546006835281842054600590935292205483909192939456fea2646970667358221220322084388f0143aa385982691acf5098e574be38d1e32ad302b12740d00ea1fe64736f6c63430007000033', 306 | linkReferences: {}, 307 | deployedLinkReferences: {}, 308 | } 309 | -------------------------------------------------------------------------------- /src/v1_contract_sources/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FunctionsCoordinator' 2 | export * from './FunctionsRouter' 3 | export * from './LinkToken' 4 | export * from './FunctionsCoordinatorTestHelper' 5 | export * from './MockV3Aggregator' 6 | export * from './TermsOfServiceAllowList' 7 | -------------------------------------------------------------------------------- /test/integration/ResponseListener.test.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | import { 3 | FulfillmentCode, 4 | FunctionsResponse, 5 | SubscriptionManager, 6 | ResponseListener, 7 | } from '../../src' 8 | import { setupLocalTestnetFixture } from '../utils' 9 | 10 | import { Contract, Wallet, utils } from 'ethers' 11 | 12 | describe('Functions toolkit classes', () => { 13 | let linkTokenAddress: string 14 | let functionsRouterAddress: string 15 | let exampleClient: Contract 16 | let close: () => Promise 17 | let allowlistedUser_A: Wallet 18 | 19 | beforeAll(async () => { 20 | const testSetup = await setupLocalTestnetFixture(8002) 21 | linkTokenAddress = testSetup.linkTokenAddress 22 | functionsRouterAddress = testSetup.functionsRouterAddress 23 | exampleClient = testSetup.exampleConsumer 24 | close = testSetup.close 25 | allowlistedUser_A = testSetup.user_A 26 | }) 27 | 28 | afterAll(async () => { 29 | await close() 30 | }) 31 | 32 | describe('Functions Listener', () => { 33 | it('Successfully waits for single response', async () => { 34 | const subscriptionManager = new SubscriptionManager({ 35 | signer: allowlistedUser_A, 36 | linkTokenAddress, 37 | functionsRouterAddress, 38 | }) 39 | await subscriptionManager.initialize() 40 | 41 | const subscriptionId = await subscriptionManager.createSubscription() 42 | await subscriptionManager.fundSubscription({ 43 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 44 | subscriptionId, 45 | }) 46 | await subscriptionManager.addConsumer({ 47 | subscriptionId, 48 | consumerAddress: exampleClient.address, 49 | txOptions: { 50 | confirmations: 1, 51 | }, 52 | }) 53 | 54 | const functionsListener = new ResponseListener({ 55 | provider: allowlistedUser_A.provider, 56 | functionsRouterAddress, 57 | }) 58 | 59 | const succReqTx = await exampleClient.sendRequest( 60 | 'return Functions.encodeUint256(1)', 61 | 1, 62 | [], 63 | [], 64 | [], 65 | subscriptionId, 66 | 100_000, 67 | ) 68 | 69 | const succReq = await succReqTx.wait() 70 | const succRequestId = succReq.events[0].topics[1] 71 | 72 | const succResponse = await functionsListener.listenForResponse(succRequestId) 73 | 74 | expect(succResponse.requestId).toBe(succRequestId) 75 | expect(succResponse.responseBytesHexstring).toBe( 76 | '0x0000000000000000000000000000000000000000000000000000000000000001', 77 | ) 78 | expect(succResponse.errorString).toBe('') 79 | expect(succResponse.returnDataBytesHexstring).toBe('0x') 80 | expect(succResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED) 81 | 82 | const errReqTx = await exampleClient.sendRequest( 83 | 'return Functions.encodeUint256(1', 84 | 1, 85 | [], 86 | [], 87 | [], 88 | subscriptionId, 89 | 100_000, 90 | ) 91 | 92 | const errReq = await errReqTx.wait(1) 93 | const errRequestId = errReq.events[0].topics[1] 94 | 95 | const errResponse = await functionsListener.listenForResponse(errRequestId) 96 | 97 | expect(errResponse.requestId).toBe(errRequestId) 98 | expect(errResponse.responseBytesHexstring).toBe('0x') 99 | expect(errResponse.errorString).toBe('syntax error, RAM exceeded, or other error') 100 | expect(errResponse.returnDataBytesHexstring).toBe('0x') 101 | expect(errResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED) 102 | }) 103 | 104 | it('Successfully waits for single response from transaction hash', async () => { 105 | const subscriptionManager = new SubscriptionManager({ 106 | signer: allowlistedUser_A, 107 | linkTokenAddress, 108 | functionsRouterAddress, 109 | }) 110 | await subscriptionManager.initialize() 111 | 112 | const subscriptionId = await subscriptionManager.createSubscription() 113 | await subscriptionManager.fundSubscription({ 114 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 115 | subscriptionId, 116 | }) 117 | await subscriptionManager.addConsumer({ 118 | subscriptionId, 119 | consumerAddress: exampleClient.address, 120 | txOptions: { 121 | confirmations: 1, 122 | }, 123 | }) 124 | 125 | const functionsListener = new ResponseListener({ 126 | provider: allowlistedUser_A.provider, 127 | functionsRouterAddress, 128 | }) 129 | 130 | const succReqTx = await exampleClient.sendRequest( 131 | 'return Functions.encodeUint256(1)', 132 | 1, 133 | [], 134 | [], 135 | [], 136 | subscriptionId, 137 | 100_000, 138 | ) 139 | 140 | const succReq = await succReqTx.wait() 141 | const succResponse = await functionsListener.listenForResponseFromTransaction( 142 | succReq.transactionHash, 143 | 1000000, 144 | 0, 145 | ) 146 | 147 | expect(succResponse.responseBytesHexstring).toBe( 148 | '0x0000000000000000000000000000000000000000000000000000000000000001', 149 | ) 150 | expect(succResponse.errorString).toBe('') 151 | expect(succResponse.returnDataBytesHexstring).toBe('0x') 152 | expect(succResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED) 153 | 154 | const errReqTx = await exampleClient.sendRequest( 155 | 'return Functions.encodeUint256(1', 156 | 1, 157 | [], 158 | [], 159 | [], 160 | subscriptionId, 161 | 100_000, 162 | ) 163 | 164 | const errReq = await errReqTx.wait(1) 165 | const errRequestId = errReq.events[0].topics[1] 166 | 167 | const errResponse = await functionsListener.listenForResponse(errRequestId) 168 | 169 | expect(errResponse.requestId).toBe(errRequestId) 170 | expect(errResponse.responseBytesHexstring).toBe('0x') 171 | expect(errResponse.errorString).toBe('syntax error, RAM exceeded, or other error') 172 | expect(errResponse.returnDataBytesHexstring).toBe('0x') 173 | expect(errResponse.fulfillmentCode).toBe(FulfillmentCode.FULFILLED) 174 | }) 175 | 176 | it('Successfully listens for responses', async () => { 177 | const subscriptionManager = new SubscriptionManager({ 178 | signer: allowlistedUser_A, 179 | linkTokenAddress, 180 | functionsRouterAddress, 181 | }) 182 | await subscriptionManager.initialize() 183 | 184 | const subscriptionId = await subscriptionManager.createSubscription() 185 | await subscriptionManager.fundSubscription({ 186 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 187 | subscriptionId, 188 | }) 189 | await subscriptionManager.addConsumer({ 190 | subscriptionId, 191 | consumerAddress: exampleClient.address, 192 | txOptions: { 193 | confirmations: 1, 194 | }, 195 | }) 196 | 197 | const functionsListener = new ResponseListener({ 198 | provider: allowlistedUser_A.provider, 199 | functionsRouterAddress, 200 | }) 201 | 202 | const responseEventEmitter = new EventEmitter() 203 | const waitForResponse = new Promise(resolve => { 204 | responseEventEmitter.on('response', resolve) 205 | }) 206 | 207 | let functionsResponse: FunctionsResponse 208 | const responseCallback = (response: FunctionsResponse) => { 209 | functionsResponse = response 210 | responseEventEmitter.emit('response') 211 | } 212 | 213 | const subIdString = subscriptionId.toString() 214 | functionsListener.listenForResponses(subIdString, responseCallback) 215 | 216 | await exampleClient.sendRequest( 217 | 'return Functions.encodeUint256(1)', 218 | 1, 219 | [], 220 | [], 221 | [], 222 | subscriptionId, 223 | 100_000, 224 | ) 225 | 226 | await waitForResponse 227 | expect(functionsResponse!.responseBytesHexstring).toBe( 228 | '0x0000000000000000000000000000000000000000000000000000000000000001', 229 | ) 230 | expect(functionsResponse!.errorString).toBe('') 231 | expect(functionsResponse!.returnDataBytesHexstring).toBe('0x') 232 | expect(functionsResponse!.fulfillmentCode).toBe(FulfillmentCode.FULFILLED) 233 | 234 | functionsListener.stopListeningForResponses() 235 | }) 236 | }) 237 | }) 238 | -------------------------------------------------------------------------------- /test/integration/apiFixture.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | 3 | export const mockOffchainSecretsEndpoints = (): nock.Scope => { 4 | return nock('https://offchain.secrets.com') 5 | .get('/valid1') 6 | .reply(200, { 7 | encryptedSecrets: '0x1234567890abcdef', 8 | }) 9 | .get('/valid2') 10 | .reply(200, { 11 | encryptedSecrets: '0x1234567890abcdef', 12 | }) 13 | .get('/valid3') 14 | .reply(200, { 15 | encryptedSecrets: '0x1234567890abcdef', 16 | }) 17 | .get('/fail') 18 | .reply(500) 19 | .get('/invalidJson') 20 | .reply(200, 'not json') 21 | .get('/invalidSecretsType') 22 | .reply(200, { 23 | invalid: 'invalid', 24 | }) 25 | .get('/invalidHex') 26 | .reply(200, { 27 | encryptedSecrets: '0x1234567890abcdefg', 28 | }) 29 | .get('/different') 30 | .reply(200, { 31 | encryptedSecrets: '0x1234567890abcdef00', 32 | }) 33 | } 34 | 35 | const nodeSetSuccessResponse = { 36 | body: { 37 | payload: { 38 | success: true, 39 | }, 40 | }, 41 | } 42 | 43 | const nodeSetFailResponse = { 44 | body: { 45 | payload: { 46 | success: false, 47 | }, 48 | }, 49 | } 50 | 51 | const exampleStorageListRow1 = { 52 | slot_id: 0, 53 | version: 0, 54 | expiration: 100_000, 55 | } 56 | 57 | const exampleStorageListRow2 = { 58 | slot_id: 1, 59 | version: 1, 60 | expiration: 200_000, 61 | } 62 | 63 | const nodeListSuccessResponse = { 64 | body: { 65 | payload: { 66 | success: true, 67 | rows: [exampleStorageListRow1, exampleStorageListRow2], 68 | }, 69 | }, 70 | } 71 | 72 | const nodeListFailResponse = { 73 | body: { 74 | payload: { 75 | success: false, 76 | }, 77 | }, 78 | } 79 | 80 | export const mockGatewayUrl = (): nock.Scope => { 81 | return nock('https://dongateway.com') 82 | .post('/uploadSuccess1') 83 | .reply(200, { 84 | result: { 85 | body: { 86 | payload: { 87 | success: true, 88 | node_responses: [nodeSetSuccessResponse, nodeSetSuccessResponse], 89 | }, 90 | }, 91 | }, 92 | }) 93 | .post('/uploadSuccess2') 94 | .reply(200, { 95 | result: { 96 | body: { 97 | payload: { 98 | success: true, 99 | node_responses: [ 100 | nodeSetSuccessResponse, 101 | nodeSetSuccessResponse, 102 | nodeSetSuccessResponse, 103 | ], 104 | }, 105 | }, 106 | }, 107 | }) 108 | .post('/1NodeFail') 109 | .reply(200, { 110 | result: { 111 | body: { 112 | payload: { 113 | success: true, 114 | node_responses: [ 115 | nodeSetSuccessResponse, 116 | { 117 | body: { 118 | payload: { 119 | success: false, 120 | }, 121 | }, 122 | }, 123 | ], 124 | }, 125 | }, 126 | }, 127 | }) 128 | .post('/allNodeFail') 129 | .reply(200, { 130 | result: { 131 | body: { 132 | payload: { 133 | success: true, 134 | node_responses: [nodeSetFailResponse, nodeSetFailResponse], 135 | }, 136 | }, 137 | }, 138 | }) 139 | .post('/listSuccess1') 140 | .reply(200, { 141 | result: { 142 | body: { 143 | payload: { 144 | success: true, 145 | node_responses: [nodeListSuccessResponse, nodeListSuccessResponse], 146 | }, 147 | }, 148 | }, 149 | }) 150 | .post('/listSuccess2') 151 | .reply(200, { 152 | result: { 153 | body: { 154 | payload: { 155 | success: true, 156 | node_responses: [ 157 | nodeListSuccessResponse, 158 | nodeListSuccessResponse, 159 | nodeListSuccessResponse, 160 | ], 161 | }, 162 | }, 163 | }, 164 | }) 165 | .post('/listFailAll') 166 | .reply(200, { 167 | result: { 168 | body: { 169 | payload: { 170 | success: true, 171 | node_responses: [nodeListFailResponse], 172 | }, 173 | }, 174 | }, 175 | }) 176 | .post('/listFail1') 177 | .reply(200, { 178 | result: { 179 | body: { 180 | payload: { 181 | success: true, 182 | node_responses: [nodeListSuccessResponse, nodeListFailResponse], 183 | }, 184 | }, 185 | }, 186 | }) 187 | .post('/listDifferentRowCounts') 188 | .reply(200, { 189 | result: { 190 | body: { 191 | payload: { 192 | success: true, 193 | node_responses: [ 194 | { 195 | body: { 196 | payload: { 197 | success: true, 198 | rows: [exampleStorageListRow1, exampleStorageListRow2], 199 | }, 200 | }, 201 | }, 202 | { 203 | body: { 204 | payload: { 205 | success: true, 206 | rows: [exampleStorageListRow1], 207 | }, 208 | }, 209 | }, 210 | ], 211 | }, 212 | }, 213 | }, 214 | }) 215 | .post('/listDifferentRows') 216 | .reply(200, { 217 | result: { 218 | body: { 219 | payload: { 220 | success: true, 221 | node_responses: [ 222 | { 223 | body: { 224 | payload: { 225 | success: true, 226 | rows: [exampleStorageListRow2], 227 | }, 228 | }, 229 | }, 230 | { 231 | body: { 232 | payload: { 233 | success: true, 234 | rows: [exampleStorageListRow1], 235 | }, 236 | }, 237 | }, 238 | ], 239 | }, 240 | }, 241 | }, 242 | }) 243 | .post('/fail') 244 | .reply(200, { 245 | result: { 246 | body: { 247 | payload: { 248 | success: false, 249 | node_responses: undefined, 250 | }, 251 | }, 252 | }, 253 | }) 254 | .post('/unexpectedGatewayResponse') 255 | .reply(200, { 256 | result: { 257 | body: { 258 | payload: { 259 | success: true, 260 | node_responses: undefined, 261 | }, 262 | }, 263 | }, 264 | }) 265 | .post('/0NodeResponses') 266 | .reply(200, { 267 | result: { 268 | body: { 269 | payload: { 270 | success: true, 271 | node_responses: [], 272 | }, 273 | }, 274 | }, 275 | }) 276 | } 277 | -------------------------------------------------------------------------------- /test/integration/fetchRequestCommitment.test.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestCommitment, SubscriptionManager } from '../../src' 2 | import { setupLocalTestnetFixture } from '../utils' 3 | 4 | import { Contract, Wallet, utils, providers } from 'ethers' 5 | 6 | const localhost = 'http://127.0.0.1:8004/' 7 | 8 | jest.retryTimes(2, { logErrorsBeforeRetry: true }) 9 | 10 | describe('fetchRequestCommitment', () => { 11 | let donId: string 12 | let linkTokenAddress: string 13 | let functionsRouterAddress: string 14 | let exampleClient: Contract 15 | let close: () => Promise 16 | let allowlistedUser_A: Wallet 17 | 18 | beforeAll(async () => { 19 | const testSetup = await setupLocalTestnetFixture(8004) 20 | donId = testSetup.donId 21 | linkTokenAddress = testSetup.linkTokenAddress 22 | functionsRouterAddress = testSetup.functionsRouterAddress 23 | exampleClient = testSetup.exampleConsumer 24 | close = testSetup.close 25 | allowlistedUser_A = testSetup.user_A 26 | }) 27 | 28 | afterAll(async () => { 29 | await close() 30 | }) 31 | 32 | it('returns the commitment for a given request ID', async () => { 33 | const subscriptionManager = new SubscriptionManager({ 34 | signer: allowlistedUser_A, 35 | linkTokenAddress, 36 | functionsRouterAddress, 37 | }) 38 | await subscriptionManager.initialize() 39 | 40 | const subscriptionId = await subscriptionManager.createSubscription() 41 | await subscriptionManager.fundSubscription({ 42 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 43 | subscriptionId, 44 | }) 45 | await subscriptionManager.addConsumer({ 46 | subscriptionId, 47 | consumerAddress: exampleClient.address, 48 | txOptions: { 49 | confirmations: 1, 50 | }, 51 | }) 52 | 53 | const reqTx = await exampleClient.sendRequest( 54 | 'return Functions.encodeUint256(1)', 55 | 1, 56 | [], 57 | [], 58 | [], 59 | subscriptionId, 60 | 100_000, 61 | ) 62 | const req = await reqTx.wait() 63 | const reqId = req.events[0].topics[1] 64 | 65 | const commitment = await fetchRequestCommitment({ 66 | requestId: reqId, 67 | provider: new providers.JsonRpcProvider(`${localhost}`), 68 | functionsRouterAddress, 69 | donId, 70 | }) 71 | 72 | expect(commitment.requestId).toEqual(reqId) 73 | }) 74 | 75 | it('returns the commitment for a given request ID within a given block range', async () => { 76 | const subscriptionManager = new SubscriptionManager({ 77 | signer: allowlistedUser_A, 78 | linkTokenAddress, 79 | functionsRouterAddress, 80 | }) 81 | await subscriptionManager.initialize() 82 | 83 | const subscriptionId = await subscriptionManager.createSubscription() 84 | await subscriptionManager.fundSubscription({ 85 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 86 | subscriptionId, 87 | }) 88 | await subscriptionManager.addConsumer({ 89 | subscriptionId, 90 | consumerAddress: exampleClient.address, 91 | txOptions: { 92 | confirmations: 1, 93 | }, 94 | }) 95 | 96 | const reqTx = await exampleClient.sendRequest( 97 | 'return Functions.encodeUint256(1)', 98 | 1, 99 | [], 100 | [], 101 | [], 102 | subscriptionId, 103 | 100_000, 104 | ) 105 | const req = await reqTx.wait() 106 | const reqId = req.events[0].topics[1] 107 | 108 | const commitment = await fetchRequestCommitment({ 109 | requestId: reqId, 110 | provider: new providers.JsonRpcProvider(`${localhost}`), 111 | functionsRouterAddress, 112 | donId, 113 | toBlock: 1000, 114 | pastBlocksToSearch: 1001, 115 | }) 116 | 117 | expect(commitment.requestId).toEqual(reqId) 118 | }) 119 | 120 | it('Throws error when unable to fetch coordinator', async () => { 121 | await expect(async () => { 122 | await fetchRequestCommitment({ 123 | requestId: '0xDummyRequestId', 124 | provider: new providers.JsonRpcProvider(`${localhost}`), 125 | functionsRouterAddress, 126 | donId: 'invalid donId', 127 | }) 128 | }).rejects.toThrowError( 129 | /Error encountered when attempting to fetch the FunctionsCoordinator address/, 130 | ) 131 | }) 132 | 133 | it('Throws error when unable to fetch matching request', async () => { 134 | await expect(async () => { 135 | await fetchRequestCommitment({ 136 | requestId: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 137 | provider: new providers.JsonRpcProvider(`${localhost}`), 138 | functionsRouterAddress, 139 | donId, 140 | }) 141 | }).rejects.toThrowError( 142 | /No request commitment event found for the provided requestId in block range/, 143 | ) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /test/integration/localFunctionsTestnet.test.ts: -------------------------------------------------------------------------------- 1 | import cbor from 'cbor' 2 | import { 3 | SubscriptionManager, 4 | decodeResult, 5 | ResponseListener, 6 | ReturnType, 7 | buildRequestCBOR, 8 | Location, 9 | CodeLanguage, 10 | } from '../../src' 11 | import { setupLocalTestnetFixture } from '../utils' 12 | 13 | import { utils } from 'ethers' 14 | 15 | import type { GetFunds } from '../../src' 16 | 17 | import type { Contract, Wallet } from 'ethers' 18 | 19 | describe('Local Functions Testnet', () => { 20 | let linkTokenAddress: string 21 | let functionsRouterAddress: string 22 | let exampleClient: Contract 23 | let close: () => Promise 24 | let allowlistedUser_A: Wallet 25 | let getFunds: GetFunds 26 | 27 | beforeAll(async () => { 28 | const testSetup = await setupLocalTestnetFixture(8003) 29 | linkTokenAddress = testSetup.linkTokenAddress 30 | functionsRouterAddress = testSetup.functionsRouterAddress 31 | exampleClient = testSetup.exampleConsumer 32 | close = testSetup.close 33 | allowlistedUser_A = testSetup.user_A 34 | getFunds = testSetup.getFunds 35 | }) 36 | 37 | afterAll(async () => { 38 | await close() 39 | }) 40 | 41 | it('Successfully fulfills a request', async () => { 42 | const subscriptionManager = new SubscriptionManager({ 43 | signer: allowlistedUser_A, 44 | linkTokenAddress, 45 | functionsRouterAddress, 46 | }) 47 | await subscriptionManager.initialize() 48 | 49 | const subscriptionId = await subscriptionManager.createSubscription() 50 | await subscriptionManager.fundSubscription({ 51 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 52 | subscriptionId, 53 | }) 54 | await subscriptionManager.addConsumer({ 55 | subscriptionId, 56 | consumerAddress: exampleClient.address, 57 | txOptions: { 58 | confirmations: 1, 59 | }, 60 | }) 61 | 62 | const functionsListener = new ResponseListener({ 63 | provider: allowlistedUser_A.provider, 64 | functionsRouterAddress, 65 | }) 66 | 67 | const reqTx = await exampleClient.sendRequest( 68 | 'return Functions.encodeString(secrets.test + " " + args[0] + " " + args[1] + bytesArgs[0] + bytesArgs[1])', 69 | 1, 70 | '0xabcd', 71 | ['hello', 'world'], 72 | ['0x1234', '0x5678'], 73 | subscriptionId, 74 | 100_000, 75 | ) 76 | 77 | const req = await reqTx.wait() 78 | const requestId = req.events[0].topics[1] 79 | const response = await functionsListener.listenForResponse(requestId) 80 | 81 | const responseString = decodeResult(response.responseBytesHexstring, ReturnType.string) 82 | expect(responseString).toBe('hello world hello world0x12340x5678') 83 | }) 84 | 85 | it('Successfully handles a request that was encoded off-chain', async () => { 86 | const subscriptionManager = new SubscriptionManager({ 87 | signer: allowlistedUser_A, 88 | linkTokenAddress, 89 | functionsRouterAddress, 90 | }) 91 | await subscriptionManager.initialize() 92 | 93 | const subscriptionId = await subscriptionManager.createSubscription() 94 | await subscriptionManager.fundSubscription({ 95 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 96 | subscriptionId, 97 | }) 98 | await subscriptionManager.addConsumer({ 99 | subscriptionId, 100 | consumerAddress: exampleClient.address, 101 | txOptions: { 102 | confirmations: 1, 103 | }, 104 | }) 105 | 106 | const functionsListener = new ResponseListener({ 107 | provider: allowlistedUser_A.provider, 108 | functionsRouterAddress, 109 | }) 110 | 111 | const encodedRequestBytes = buildRequestCBOR({ 112 | source: 113 | 'return Functions.encodeString(secrets.test + " " + args[0] + " " + args[1] + bytesArgs[0] + bytesArgs[1])', 114 | codeLanguage: CodeLanguage.JavaScript, 115 | codeLocation: Location.Inline, 116 | args: ['hello', 'world'], 117 | bytesArgs: ['0x1234', '0x5678'], 118 | secretsLocation: Location.Remote, 119 | encryptedSecretsReference: '0xabcd', 120 | }) 121 | 122 | const reqTx = await exampleClient.sendEncodedRequest( 123 | encodedRequestBytes, 124 | subscriptionId, 125 | 100_000, 126 | ) 127 | 128 | const req = await reqTx.wait() 129 | const requestId = req.events[0].topics[1] 130 | const response = await functionsListener.listenForResponse(requestId) 131 | 132 | const responseString = decodeResult(response.responseBytesHexstring, ReturnType.string) 133 | expect(responseString).toBe('hello world hello world0x12340x5678') 134 | }) 135 | 136 | it('Successfully handles a request that has incorrect types', async () => { 137 | const subscriptionManager = new SubscriptionManager({ 138 | signer: allowlistedUser_A, 139 | linkTokenAddress, 140 | functionsRouterAddress, 141 | }) 142 | await subscriptionManager.initialize() 143 | 144 | const subscriptionId = await subscriptionManager.createSubscription() 145 | await subscriptionManager.fundSubscription({ 146 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 147 | subscriptionId, 148 | }) 149 | await subscriptionManager.addConsumer({ 150 | subscriptionId, 151 | consumerAddress: exampleClient.address, 152 | txOptions: { 153 | confirmations: 1, 154 | }, 155 | }) 156 | 157 | const functionsListener = new ResponseListener({ 158 | provider: allowlistedUser_A.provider, 159 | functionsRouterAddress, 160 | }) 161 | 162 | const encodedRequestBytes = 163 | '0x' + 164 | cbor 165 | .encodeCanonical({ 166 | codeLocation: Location.Inline, 167 | codeLanguage: CodeLanguage.JavaScript, 168 | source: 1234, 169 | }) 170 | .toString('hex') 171 | 172 | const reqTx = await exampleClient.sendEncodedRequest( 173 | encodedRequestBytes, 174 | subscriptionId, 175 | 100_000, 176 | ) 177 | 178 | const req = await reqTx.wait() 179 | const requestId = req.events[0].topics[1] 180 | const response = await functionsListener.listenForResponse(requestId) 181 | 182 | expect(response.errorString).toBe('source param is missing or invalid') 183 | }) 184 | 185 | it('Successfully handles invalid request data', async () => { 186 | const subscriptionManager = new SubscriptionManager({ 187 | signer: allowlistedUser_A, 188 | linkTokenAddress, 189 | functionsRouterAddress, 190 | }) 191 | await subscriptionManager.initialize() 192 | 193 | const subscriptionId = await subscriptionManager.createSubscription() 194 | await subscriptionManager.fundSubscription({ 195 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 196 | subscriptionId, 197 | }) 198 | await subscriptionManager.addConsumer({ 199 | subscriptionId, 200 | consumerAddress: exampleClient.address, 201 | txOptions: { 202 | confirmations: 1, 203 | }, 204 | }) 205 | 206 | const functionsListener = new ResponseListener({ 207 | provider: allowlistedUser_A.provider, 208 | functionsRouterAddress, 209 | }) 210 | 211 | const reqTx = await exampleClient.sendEncodedRequest('0xabcd', subscriptionId, 100_000) 212 | 213 | const req = await reqTx.wait() 214 | const requestId = req.events[0].topics[1] 215 | const response = await functionsListener.listenForResponse(requestId) 216 | 217 | expect(response.errorString).toBe('CBOR parsing error') 218 | }) 219 | 220 | it('Successfully aggregates a random number', async () => { 221 | const subscriptionManager = new SubscriptionManager({ 222 | signer: allowlistedUser_A, 223 | linkTokenAddress, 224 | functionsRouterAddress, 225 | }) 226 | await subscriptionManager.initialize() 227 | 228 | const subscriptionId = await subscriptionManager.createSubscription() 229 | await subscriptionManager.fundSubscription({ 230 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 231 | subscriptionId, 232 | }) 233 | await subscriptionManager.addConsumer({ 234 | subscriptionId, 235 | consumerAddress: exampleClient.address, 236 | txOptions: { 237 | confirmations: 1, 238 | }, 239 | }) 240 | 241 | const functionsListener = new ResponseListener({ 242 | provider: allowlistedUser_A.provider, 243 | functionsRouterAddress, 244 | }) 245 | 246 | const reqTx = await exampleClient.sendRequest( 247 | 'return Functions.encodeUint256(Math.floor((Math.random() + 0.1) * 1_000_000_000))', 248 | 1, 249 | '0xabcd', 250 | ['hello', 'world'], 251 | ['0x1234', '0x5678'], 252 | subscriptionId, 253 | 100_000, 254 | ) 255 | 256 | const req = await reqTx.wait() 257 | const requestId = req.events[0].topics[1] 258 | const response = await functionsListener.listenForResponse(requestId) 259 | 260 | expect(response.responseBytesHexstring.length).toBeGreaterThan(2) 261 | }) 262 | 263 | it('Successfully aggregates a random error', async () => { 264 | const subscriptionManager = new SubscriptionManager({ 265 | signer: allowlistedUser_A, 266 | linkTokenAddress, 267 | functionsRouterAddress, 268 | }) 269 | await subscriptionManager.initialize() 270 | 271 | const subscriptionId = await subscriptionManager.createSubscription() 272 | await subscriptionManager.fundSubscription({ 273 | juelsAmount: utils.parseUnits('1', 'ether').toString(), 274 | subscriptionId, 275 | }) 276 | await subscriptionManager.addConsumer({ 277 | subscriptionId, 278 | consumerAddress: exampleClient.address, 279 | txOptions: { 280 | confirmations: 1, 281 | }, 282 | }) 283 | 284 | const functionsListener = new ResponseListener({ 285 | provider: allowlistedUser_A.provider, 286 | functionsRouterAddress, 287 | }) 288 | 289 | const reqTx = await exampleClient.sendRequest( 290 | 'throw Error(`${Math.floor((Math.random() + 0.1) * 100)}`)', 291 | 1, 292 | '0xabcd', 293 | ['hello', 'world'], 294 | ['0x1234', '0x5678'], 295 | subscriptionId, 296 | 100_000, 297 | ) 298 | 299 | const req = await reqTx.wait() 300 | const requestId = req.events[0].topics[1] 301 | const response = await functionsListener.listenForResponse(requestId) 302 | 303 | expect(parseInt(response.errorString)).toBeGreaterThan(0) 304 | }) 305 | 306 | it('getFunds throws error for invalid weiAmount', async () => { 307 | await expect(async () => { 308 | // @ts-ignore 309 | await getFunds('0xc0ffee254729296a45a3885639AC7E10F9d54979', { weiAmount: 1 }) 310 | }).rejects.toThrow(/weiAmount must be a BigInt or string/) 311 | }) 312 | 313 | it('getFunds throws error for invalid juelsAmount', async () => { 314 | await expect(async () => { 315 | // @ts-ignore 316 | await getFunds('0xc0ffee254729296a45a3885639AC7E10F9d54979', { juelsAmount: 1 }) 317 | }).rejects.toThrow(/juelsAmount must be a BigInt or string/) 318 | }) 319 | }) 320 | -------------------------------------------------------------------------------- /test/unit/Functions.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from 'axios' 2 | import { FunctionsModule } from '../../src/simulateScript/Functions' 3 | import { mockApi } from './apiFixture' 4 | 5 | describe('Encoding functions', () => { 6 | const functionsModule = new FunctionsModule().buildFunctionsmodule(0) 7 | 8 | describe('success cases', () => { 9 | it('encodes uint256', () => { 10 | expect(functionsModule.encodeUint256(64)).toEqual( 11 | Buffer.from('0000000000000000000000000000000000000000000000000000000000000040', 'hex'), 12 | ) 13 | }) 14 | 15 | it('encodes 0 as uint256', () => { 16 | expect(functionsModule.encodeUint256(0)).toEqual( 17 | Buffer.from('0000000000000000000000000000000000000000000000000000000000000000', 'hex'), 18 | ) 19 | }) 20 | 21 | it('encodes positive int256', () => { 22 | expect(functionsModule.encodeInt256(64)).toEqual( 23 | Buffer.from('0000000000000000000000000000000000000000000000000000000000000040', 'hex'), 24 | ) 25 | }) 26 | 27 | it('encodes negative int256', () => { 28 | expect(functionsModule.encodeInt256(-64)).toEqual( 29 | Buffer.from('ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc0', 'hex'), 30 | ) 31 | }) 32 | 33 | it('encodes string', () => { 34 | expect(functionsModule.encodeString('hello world')).toEqual(Buffer.from('hello world')) 35 | }) 36 | }) 37 | 38 | describe('failure cases', () => { 39 | it('errors when encoding non-number value as uint256', () => { 40 | expect(() => functionsModule.encodeUint256('64' as unknown as number)).toThrowError( 41 | 'encodeUint256 invalid input', 42 | ) 43 | }) 44 | 45 | it('errors when encoding non-integer value as uint256', () => { 46 | expect(() => functionsModule.encodeUint256(64.4)).toThrowError('encodeUint256 invalid input') 47 | }) 48 | 49 | it('errors when encoding negative value as uint256', () => { 50 | expect(() => functionsModule.encodeUint256(-64)).toThrowError('encodeUint256 invalid input') 51 | expect(() => functionsModule.encodeUint256(BigInt(-64))).toThrowError( 52 | 'encodeUint256 invalid input', 53 | ) 54 | }) 55 | 56 | it('errors when encoding a value that is too large for uint256', () => { 57 | expect(() => 58 | functionsModule.encodeUint256( 59 | BigInt('115792089237316195423570985008687907853269984665640564039457584007913129639936'), 60 | ), 61 | ).toThrowError('encodeUint256 invalid input') 62 | }) 63 | 64 | it('errors when encoding non-number value as int256', () => { 65 | expect(() => functionsModule.encodeInt256('64' as unknown as number)).toThrowError( 66 | 'encodeInt256 invalid input', 67 | ) 68 | }) 69 | 70 | it('errors when encoding non-integer value as int256', () => { 71 | expect(() => functionsModule.encodeInt256(64.4)).toThrowError('encodeInt256 invalid input') 72 | }) 73 | 74 | it('errors when encoding a value that is too large for int256', () => { 75 | expect(() => 76 | functionsModule.encodeInt256( 77 | BigInt('57896044618658097711785492504343953926634992332820282019728792003956564819968'), 78 | ), 79 | ).toThrowError('encodeInt256 invalid input') 80 | }) 81 | 82 | it('errors when encoding a value that is too small for int256', () => { 83 | expect(() => 84 | functionsModule.encodeInt256( 85 | BigInt('-57896044618658097711785492504343953926634992332820282019728792003956564819969'), 86 | ), 87 | ).toThrowError('encodeInt256 invalid input') 88 | }) 89 | 90 | it('errors when encoding non-string value as string', () => { 91 | expect(() => functionsModule.encodeString(0 as unknown as string)).toThrowError( 92 | 'encodeString invalid input', 93 | ) 94 | }) 95 | }) 96 | }) 97 | 98 | describe('makeHttpRequest', () => { 99 | it('successfully performs HTTP query', async () => { 100 | mockApi() 101 | const functionsModule = new FunctionsModule().buildFunctionsmodule(1) 102 | const response = (await functionsModule.makeHttpRequest({ 103 | url: 'http://mockurl.com/', 104 | })) as AxiosResponse 105 | expect(response.data).toEqual({ response: 'test response' }) 106 | }) 107 | 108 | it('successfully handles error', async () => { 109 | mockApi() 110 | const functionsModule = new FunctionsModule().buildFunctionsmodule(1) 111 | const response = (await functionsModule.makeHttpRequest({ 112 | url: 'http://mockurl.com/error', 113 | })) as AxiosError 114 | expect(response.message).toBe('Request failed with status code 400') 115 | }) 116 | 117 | it('errors when maximum allowed HTTP query count is exceeded', async () => { 118 | const functionsModule = new FunctionsModule().buildFunctionsmodule(0) 119 | await expect(async () => 120 | functionsModule.makeHttpRequest({ url: 'http://mockurl.com/' }), 121 | ).rejects.toThrowError('exceeded numAllowedQueries') 122 | }) 123 | 124 | it('errors when maximum timeout is exceeded', async () => { 125 | const functionsModule = new FunctionsModule().buildFunctionsmodule(1) 126 | await expect(async () => 127 | functionsModule.makeHttpRequest({ url: 'http://mockurl.com/', timeout: 9001 }), 128 | ).rejects.toThrowError('HTTP request timeout >9000') 129 | }) 130 | 131 | it('errors when maximum allowed HTTP query count is exceeded', async () => { 132 | const functionsModule = new FunctionsModule().buildFunctionsmodule(1) 133 | await expect(async () => 134 | functionsModule.makeHttpRequest({ 135 | url: 'https://thisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolongthisurlistoolong.com', 136 | }), 137 | ).rejects.toThrowError('HTTP request URL length >2048') 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /test/unit/apiFixture.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | 3 | export const mockApi = (): nock.Scope => 4 | nock('http://mockurl.com') 5 | .get('/') 6 | .reply(200, () => ({ response: 'test response' }), [ 7 | 'Content-Type', 8 | 'application/json', 9 | 'Connection', 10 | 'close', 11 | 'Vary', 12 | 'Accept-Encoding', 13 | 'Vary', 14 | 'Origin', 15 | ]) 16 | .get('/error') 17 | .reply(400) 18 | 19 | export const mockGithubApi_HappyPath = (): nock.Scope => { 20 | return nock('https://api.github.com') 21 | .get('/user') 22 | .reply( 23 | 200, 24 | { 25 | // github response stub 26 | html_url: 'https://github.com/octocat', 27 | type: 'User', 28 | }, 29 | { 30 | // headers 31 | 'x-oauth-scopes': 'gist, some-scope', 32 | }, 33 | ) 34 | .post('/gists') 35 | .reply(200, { html_url: 'https://fake-github.com/gists/1234' }) 36 | .delete('/gists/abcde12345') 37 | .reply(200) 38 | } 39 | 40 | export const mockGithubApi_PATScopeFails = (): nock.Scope => { 41 | return nock('https://api.github.com').get('/user').reply(401) 42 | } 43 | 44 | export const mockGithubApi_ValidToken_InvalidScope = (): nock.Scope => { 45 | return nock('https://api.github.com').get('/user').reply( 46 | 200, 47 | { 48 | // github response stub 49 | html_url: 'https://github.com/octocat', 50 | type: 'User', 51 | }, 52 | { 53 | // headers 54 | 'x-oauth-scopes': 'no-gist, some-scope', 55 | }, 56 | ) 57 | } 58 | 59 | export const mockGithubApi_GistFail = (): nock.Scope => { 60 | return nock('https://api.github.com') 61 | .get('/user') 62 | .reply(200, { 63 | html_url: 'https://github.com/octocat', 64 | type: 'User', 65 | }) 66 | .post('/gists') 67 | .reply(402) 68 | } 69 | 70 | export const mockGithubApi_DeleteGist_Errors = (): nock.Scope => { 71 | return nock('https://api.github.com').delete('/gists/12345abcdef').reply(401) 72 | } 73 | -------------------------------------------------------------------------------- /test/unit/buildRequestCBOR.test.ts: -------------------------------------------------------------------------------- 1 | import { buildRequestCBOR } from '../../src' 2 | 3 | describe('buildRequestCBOR', () => { 4 | it('correctly encodes a valid request', () => { 5 | const result = buildRequestCBOR({ 6 | codeLocation: 0, 7 | codeLanguage: 0, 8 | source: 'test', 9 | encryptedSecretsReference: '0xabcdef', 10 | secretsLocation: 1, 11 | args: ['arg1', 'arg2'], 12 | bytesArgs: ['0x123456', '0xabcdef'], 13 | }) 14 | 15 | expect(typeof result).toBe('string') 16 | expect(result.startsWith('0x')).toBe(true) 17 | }) 18 | 19 | it('throws error for invalid codeLocation', () => { 20 | expect(() => 21 | buildRequestCBOR({ 22 | codeLocation: 'invalid', 23 | codeLanguage: 0, 24 | source: 'test', 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | } as any), 27 | ).toThrow('Invalid codeLocation') 28 | 29 | expect(() => 30 | buildRequestCBOR({ 31 | codeLocation: 1, 32 | codeLanguage: 0, 33 | source: 'test', 34 | }), 35 | ).toThrow('Invalid codeLocation') 36 | }) 37 | 38 | it('throws error for invalid codeLanguage', () => { 39 | expect(() => 40 | buildRequestCBOR({ 41 | codeLocation: 0, 42 | codeLanguage: 'invalid', 43 | source: 'test', 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | } as any), 46 | ).toThrow('Invalid codeLanguage') 47 | 48 | expect(() => 49 | buildRequestCBOR({ 50 | codeLocation: 0, 51 | codeLanguage: 1, 52 | source: 'test', 53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 | } as any), 55 | ).toThrow('Invalid codeLanguage') 56 | }) 57 | 58 | it('throws error for invalid source', () => { 59 | expect(() => 60 | buildRequestCBOR({ 61 | codeLocation: 0, 62 | codeLanguage: 0, 63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 64 | source: 123 as any, 65 | }), 66 | ).toThrow('Invalid source') 67 | }) 68 | 69 | it('throws error for invalid encryptedSecretsReference', () => { 70 | expect(() => 71 | buildRequestCBOR({ 72 | codeLocation: 0, 73 | codeLanguage: 0, 74 | source: 'test', 75 | encryptedSecretsReference: 'invalid', 76 | secretsLocation: 1, 77 | }), 78 | ).toThrow('Invalid encryptedSecretsReference') 79 | }) 80 | 81 | it('throws error for invalid secretsLocation', () => { 82 | expect(() => 83 | buildRequestCBOR({ 84 | codeLocation: 0, 85 | codeLanguage: 0, 86 | source: 'test', 87 | encryptedSecretsReference: '0xabcdef', 88 | secretsLocation: 3, 89 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 | } as any), 91 | ).toThrow('Invalid secretsLocation') 92 | }) 93 | 94 | it('throws error for invalid args', () => { 95 | expect(() => 96 | buildRequestCBOR({ 97 | codeLocation: 0, 98 | codeLanguage: 0, 99 | source: 'test', 100 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 101 | args: [123 as any], 102 | }), 103 | ).toThrow('Invalid args') 104 | 105 | expect(() => 106 | buildRequestCBOR({ 107 | codeLocation: 0, 108 | codeLanguage: 0, 109 | source: 'test', 110 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 111 | args: ['valid', 123 as any], 112 | }), 113 | ).toThrow('Invalid args') 114 | }) 115 | 116 | it('throws error for invalid bytesArgs', () => { 117 | expect(() => 118 | buildRequestCBOR({ 119 | codeLocation: 0, 120 | codeLanguage: 0, 121 | source: 'test', 122 | bytesArgs: ['invalid'], 123 | }), 124 | ).toThrow('Invalid bytesArgs') 125 | 126 | expect(() => 127 | buildRequestCBOR({ 128 | codeLocation: 0, 129 | codeLanguage: 0, 130 | source: 'test', 131 | bytesArgs: ['0xabcdef', 'invalid'], 132 | }), 133 | ).toThrow('Invalid bytesArgs') 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /test/unit/decode_result.test.ts: -------------------------------------------------------------------------------- 1 | import { decodeResult, ReturnType } from '../../src/index' 2 | 3 | describe('decodeResult', () => { 4 | it.each([ 5 | { 6 | result: '0x', 7 | expectedDataType: ReturnType.string, 8 | decodedResult: '', 9 | label: 'decodes empty string', 10 | }, 11 | { 12 | result: '0x', 13 | expectedDataType: ReturnType.uint256, 14 | decodedResult: BigInt(0), 15 | label: 'decodes empty uint256', 16 | }, 17 | { 18 | result: '0x', 19 | expectedDataType: ReturnType.int256, 20 | decodedResult: BigInt(0), 21 | label: 'decodes empty int256', 22 | }, 23 | { 24 | result: '0x48656c6c6f2c20576f726c6421', 25 | expectedDataType: ReturnType.string, 26 | decodedResult: 'Hello, World!', 27 | label: 'decodes string', 28 | }, 29 | { 30 | result: '0x123ABC', 31 | expectedDataType: ReturnType.uint256, 32 | decodedResult: BigInt(1194684), 33 | label: 'decodes uint256', 34 | }, 35 | { 36 | result: '0x0000000000000000000000000000000000000000000000000000000000000064', 37 | expectedDataType: ReturnType.int256, 38 | decodedResult: BigInt(100), 39 | label: 'decodes signed (positive) integer', 40 | }, 41 | { 42 | result: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0b5f13', 43 | expectedDataType: ReturnType.int256, 44 | decodedResult: BigInt(-16031981), 45 | label: 'decodes signed (negative) integer', 46 | }, 47 | { 48 | result: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0b5f13', 49 | expectedDataType: ReturnType.bytes, 50 | decodedResult: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0b5f13', 51 | label: 'decodes bytes', 52 | }, 53 | ])('$label', ({ result, expectedDataType, decodedResult }) => { 54 | expect(decodeResult(result, expectedDataType).toString()).toBe(decodedResult.toString()) 55 | }) 56 | 57 | it('throws error if expectedDataType is invalid', () => { 58 | // @ts-ignore 59 | expect(() => decodeResult('0x123ABC', 'invalid')).toThrow( 60 | // eslint-disable-next-line no-regex-spaces 61 | /not valid. Must be one of the following/, 62 | ) 63 | }) 64 | it('throws with invalid hex', () => { 65 | expect(() => 66 | // @ts-ignore 67 | decodeResult('ABffffffffffffffffffffffffffffffffffffffffffffffff0b5f13', ReturnType.int256), 68 | ).toThrow(/ not a valid hexadecimal string/) 69 | }) 70 | 71 | it('throws if hex result exceeds size for signed int 256', () => { 72 | const oversizedInt = '0x0000000000000000000000000000000000000000000000000000000000000f50ed' 73 | expect(() => 74 | // @ts-ignore 75 | decodeResult(oversizedInt, 'int256'), 76 | ).toThrow(/too large for int256/) 77 | }) 78 | 79 | it('throws if hex result exceeds size for unsigned int 256', () => { 80 | const oversizedInt = '0x0000000000000000000000000000000000000000000000000000000000000f50ed' 81 | expect(() => 82 | // @ts-ignore 83 | decodeResult(oversizedInt, 'uint256'), 84 | ).toThrow(/too large for uint256/) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/unit/frontendAllowedModules.test.ts: -------------------------------------------------------------------------------- 1 | import { safeRequire, AllowedModules } from '../../src/simulateScript/frontendAllowedModules' 2 | 3 | describe('safeRequire', () => { 4 | it('allows importing buffer', () => { 5 | expect(() => safeRequire('buffer')).not.toThrow() 6 | expect(() => safeRequire('crypto')).not.toThrow() 7 | expect(() => safeRequire('querystring')).not.toThrow() 8 | expect(() => safeRequire('string_decoder')).not.toThrow() 9 | expect(() => safeRequire('url')).not.toThrow() 10 | expect(() => safeRequire('util')).not.toThrow() 11 | }) 12 | 13 | it('prevents importing non-allowed built-in modules', () => { 14 | expect(() => safeRequire('child_process' as AllowedModules)).toThrow() 15 | expect(() => safeRequire('dns' as AllowedModules)).toThrow() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/unit/frontendSimulateScript.test.ts: -------------------------------------------------------------------------------- 1 | import { simulateScript } from '../../src/simulateScript/frontendSimulateScript' 2 | import { mockApi } from './apiFixture' 3 | import crypto from 'crypto' 4 | 5 | describe('frontendSimulateScript', () => { 6 | it('simulates script', async () => { 7 | mockApi() 8 | // This example gets a response from an API, generates a hash of the response and then encodes that hash as a string. 9 | // It is a contrived example in order to utilize a built-in Node.js module. 10 | const result = await simulateScript({ 11 | source: 12 | 'const crypto = require("crypto");\ 13 | console.log(args[0] + secrets.key);\ 14 | const result = await Functions.makeHttpRequest({ url: "http://mockurl.com/" });\ 15 | const hash = crypto.createHash("sha256");\ 16 | hash.update(result.data.response);\ 17 | const digest = hash.digest("hex");\ 18 | return Functions.encodeString(digest);', 19 | args: ['MockArg'], 20 | secrets: { 21 | key: 'MockSecret', 22 | }, 23 | }) 24 | 25 | const hash = crypto.createHash('sha256') 26 | hash.update('test response') 27 | const expectedDigest = hash.digest('hex') 28 | const expectedResponseString = Buffer.from(expectedDigest, 'utf8') 29 | const expectedResponseStringInHexFormat = `0x${expectedResponseString.toString('hex')}` 30 | 31 | expect(result.result).toBe(expectedResponseStringInHexFormat) 32 | expect(result.capturedStdout).toBe('MockArgMockSecret\n') 33 | }) 34 | 35 | it('returns correct response for empty buffer', async () => { 36 | const result = await simulateScript({ 37 | source: 'return Buffer.from("")', 38 | }) 39 | 40 | expect(result.result).toBe('0x0') 41 | }) 42 | 43 | it('returns error when attempting to return non-buffer value', async () => { 44 | const result = await simulateScript({ 45 | source: 'return 1', 46 | }) 47 | 48 | expect(result.error).toEqual(Error('Error: returned value not a Buffer')) 49 | }) 50 | 51 | it('returns error when attempting to return too large of a response', async () => { 52 | const result = await simulateScript({ 53 | source: 'return Buffer.from("a".repeat(257))', 54 | }) 55 | 56 | expect(result.error).toEqual(Error('Error: returned Buffer >256 bytes')) 57 | }) 58 | 59 | it('returns error when attempting to use eval', async () => { 60 | const result = await simulateScript({ 61 | source: 'let a = 0;eval(a = 1);return Functions.encodeUint256(a)', 62 | }) 63 | 64 | expect(result.error).toEqual(Error('Error: eval not allowed')) 65 | }) 66 | 67 | it('returns error when attempting to import disallowed module', async () => { 68 | const result = await simulateScript({ 69 | source: 'const fs = require("fs");return Functions.encodeUint256(1)', 70 | }) 71 | 72 | expect(result.error).toEqual(Error('Error: Import of module fs not allowed')) 73 | }) 74 | 75 | it('returns error for invalid source', async () => { 76 | const result = await simulateScript({ 77 | source: 5 as unknown as string, 78 | }) 79 | 80 | expect(result.error).toEqual(Error('Error: Invalid source code')) 81 | }) 82 | 83 | it('returns error when args type is incorrect', async () => { 84 | const result = await simulateScript({ 85 | source: 'return Buffer.from("")', 86 | args: 'incorrect' as unknown as string[], 87 | }) 88 | 89 | expect(result.error).toEqual(Error('Error: Invalid args')) 90 | }) 91 | 92 | it('returns error when args does not contain all strings', async () => { 93 | const result = await simulateScript({ 94 | source: 'return Buffer.from("")', 95 | args: ['string', 5] as unknown as string[], 96 | }) 97 | 98 | expect(result.error).toEqual(Error('Error: Invalid args')) 99 | }) 100 | 101 | it('returns error when secrets type is not correct', async () => { 102 | const result = await simulateScript({ 103 | source: 'return Buffer.from("")', 104 | secrets: 5 as unknown as Record, 105 | }) 106 | 107 | expect(result.error).toEqual(Error('Error: secrets param not a string map')) 108 | }) 109 | 110 | it('returns error when secrets contains incorrect type', async () => { 111 | const result = await simulateScript({ 112 | source: 'return Buffer.from("")', 113 | secrets: { key: 5 } as unknown as Record, 114 | }) 115 | 116 | expect(result.error).toEqual(Error('Error: secrets param not a string map')) 117 | }) 118 | 119 | it('throws error when execution time is exceeded', async () => { 120 | const result = await simulateScript({ 121 | source: 'for (let i =0; i < 100_000_000; i++){}; return Buffer.from("")', 122 | maxExecutionDurationMs: 0, 123 | }) 124 | 125 | expect(result.error?.message).toContain('Execution time exceeded') 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /test/unit/offchain_storage.test.ts: -------------------------------------------------------------------------------- 1 | import { createGist, deleteGist } from '../../src' 2 | import { 3 | mockGithubApi_GistFail, 4 | mockGithubApi_HappyPath, 5 | mockGithubApi_PATScopeFails, 6 | mockGithubApi_ValidToken_InvalidScope, 7 | mockGithubApi_DeleteGist_Errors, 8 | } from './apiFixture' 9 | 10 | describe('Offchain Storage - Gists', () => { 11 | describe('createGist', () => { 12 | it('create gist ', async () => { 13 | mockGithubApi_HappyPath() 14 | const response: string = await createGist('gh-access-token', ' test GIST!') 15 | expect(response).toEqual('https://fake-github.com/gists/1234/raw') 16 | }) 17 | 18 | it('throws if gist creation fails ', async () => { 19 | mockGithubApi_GistFail() 20 | 21 | await expect(async () => { 22 | await createGist('gh-access-token', ' test GIST!') 23 | }).rejects.toThrowError(/Failed to create Gist/) 24 | }) 25 | 26 | it('throws if accessing github user token details fails', async () => { 27 | mockGithubApi_PATScopeFails() 28 | 29 | await expect(async () => { 30 | await createGist('gh-access-token', ' test GIST!') 31 | }).rejects.toThrowError(/Check that your access token is valid/) 32 | }) 33 | 34 | it('throws with classic token with no gist in scope', async () => { 35 | mockGithubApi_ValidToken_InvalidScope() 36 | 37 | await expect(async () => { 38 | await createGist('gh-access-token', ' test GIST!') 39 | }).rejects.toThrowError(/does not have permissions to read and write Gists/) 40 | }) 41 | 42 | it('throws an error if content is not a string', async () => { 43 | await expect(async () => { 44 | // @ts-ignore 45 | await createGist('gh-access-token', 1234) 46 | }).rejects.toThrowError(/Gist content must be a string/) 47 | }) 48 | 49 | it('throws an error if no access token is provided', async () => { 50 | await expect(async () => { 51 | // @ts-ignore 52 | await createGist(undefined, 'test GIST!') 53 | }).rejects.toThrowError(/Github API token is required/) 54 | }) 55 | }) 56 | 57 | describe('deleteGist', () => { 58 | it('deletes gist', async () => { 59 | mockGithubApi_HappyPath() 60 | const response: boolean = await deleteGist( 61 | 'gh-access-token', 62 | 'https://gist.github.com/fake-user/abcde12345', 63 | ) 64 | expect(response).toBe(true) 65 | }) 66 | 67 | it('deletes gist with /', async () => { 68 | mockGithubApi_HappyPath() 69 | const response: boolean = await deleteGist( 70 | 'gh-access-token', 71 | 'https://gist.github.com/fake-user/abcde12345/', 72 | ) 73 | expect(response).toBe(true) 74 | }) 75 | 76 | it('deletes gist with /raw', async () => { 77 | mockGithubApi_HappyPath() 78 | const response: boolean = await deleteGist( 79 | 'gh-access-token', 80 | 'https://gist.github.com/fake-user/abcde12345/raw', 81 | ) 82 | expect(response).toBe(true) 83 | }) 84 | 85 | it('deletes gist with /raw/', async () => { 86 | mockGithubApi_HappyPath() 87 | const response: boolean = await deleteGist( 88 | 'gh-access-token', 89 | 'gist.github.com/fake-user/abcde12345/raw/', 90 | ) 91 | expect(response).toBe(true) 92 | }) 93 | 94 | it('deletes gist with #file-filename', async () => { 95 | mockGithubApi_HappyPath() 96 | const response: boolean = await deleteGist( 97 | 'gh-access-token', 98 | 'https://gist.github.com/fake-user/abcde12345#file-filename', 99 | ) 100 | expect(response).toBe(true) 101 | }) 102 | 103 | it('throws an error for an invalid gist URL', async () => { 104 | await expect(async () => { 105 | await deleteGist('gh-access-token', 'https://gist.github.com/fake-user') 106 | }).rejects.toThrowError(/Invalid Gist URL/) 107 | }) 108 | 109 | it('throws on error when github api delete fails', async () => { 110 | mockGithubApi_DeleteGist_Errors() 111 | await expect(async () => { 112 | await deleteGist('gh-access-token', 'https://gist.github.com/fake-user/12345abcdef') 113 | }).rejects.toThrowError(/Error deleting Gist/) 114 | }) 115 | 116 | it('throws on error no access token provided', async () => { 117 | await expect(async () => { 118 | // @ts-ignore 119 | await deleteGist(undefined, 'https://gist.github.com/etc') 120 | }).rejects.toThrowError(/Github API token is required/) 121 | }) 122 | 123 | it('throws on error no Gist URL provided', async () => { 124 | await expect(async () => { 125 | // @ts-ignore 126 | await deleteGist('gh-access-token', undefined) 127 | }).rejects.toThrowError(/ Gist URL is required/) 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /test/unit/safePower.test.ts: -------------------------------------------------------------------------------- 1 | import { safePow } from '../../src/simulateScript/safePow' 2 | 3 | describe('safePow', () => { 4 | it.each([ 5 | { base: 2, exponent: 3, expected: BigInt(2) ** BigInt(3), label: 'safePow(2, 3) === 2 ** 3' }, 6 | { 7 | base: BigInt(2), 8 | exponent: BigInt(3), 9 | expected: BigInt(2) ** BigInt(3), 10 | label: 'safePow(BigInt(2), BigInt(3)) === BigInt(2) ** BigInt(3)', 11 | }, 12 | { 13 | base: BigInt(2), 14 | exponent: BigInt(256), 15 | expected: BigInt(2) ** BigInt(256), 16 | label: 'safePow(BigInt(2), BigInt(256)) === BigInt(2) ** BigInt(256)', 17 | }, 18 | { 19 | base: BigInt(-5), 20 | exponent: BigInt(3), 21 | expected: BigInt(-5) ** BigInt(3), 22 | label: 'safePow(BigInt(-5), BigInt(3)) === BigInt(-5) ** BigInt(3)', 23 | }, 24 | ])('$label', ({ base, exponent, expected }) => { 25 | expect(safePow(base, exponent)).toBe(expected) 26 | }) 27 | 28 | it('should throw when unsupported values are passed', () => { 29 | expect(() => safePow(1.5, 2)).toThrow('safePow invalid base') 30 | expect(() => safePow(2, 2.2)).toThrow('safePow invalid exponent') 31 | expect(() => safePow(2, -5)).toThrow('safePow invalid exponent (must be positive)') 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/unit/simulateScript.test.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | 3 | import { simulateScript } from '../../src' 4 | 5 | import type { AddressInfo } from 'net' 6 | 7 | describe('simulateScript', () => { 8 | describe('successful simulation', () => { 9 | it('simulates script', async () => { 10 | const result = await simulateScript({ 11 | source: 12 | "console.log('start'); return Functions.encodeString(args[0] + bytesArgs[0] + secrets.key);", 13 | args: ['MockArg'], 14 | bytesArgs: ['0x1234'], 15 | secrets: { 16 | key: 'MockSecret', 17 | }, 18 | }) 19 | 20 | const expected = { 21 | capturedTerminalOutput: 'start\n', 22 | responseBytesHexstring: '0x4d6f636b4172673078313233344d6f636b536563726574', 23 | } 24 | 25 | expect(result).toEqual(expected) 26 | }) 27 | 28 | it('simulates script with HTTP request', async () => { 29 | const server = createTestServer() 30 | const port = (server.address() as AddressInfo).port 31 | 32 | const result = await simulateScript({ 33 | source: `const response = await fetch('http://localhost:${port}'); const jsonResponse = await response.json(); console.log(jsonResponse); return Functions.encodeString(jsonResponse.message);`, 34 | }) 35 | 36 | const expected = { 37 | capturedTerminalOutput: '{ message: "Hello, world!" }\n', 38 | responseBytesHexstring: '0x48656c6c6f2c20776f726c6421', 39 | } 40 | 41 | expect(result).toEqual(expected) 42 | 43 | server.close() 44 | }) 45 | 46 | it('should handle multiple simultaneous HTTP requests', async () => { 47 | const server = createTestServer() 48 | const port = (server.address() as AddressInfo).port 49 | const url = `http://localhost:${port}` 50 | 51 | const result = await simulateScript({ 52 | source: `const req1 = fetch('${url}'); const req2 = fetch('${url}'); const req3 = fetch('${url}');\ 53 | const [ res1, res2, res3 ] = await Promise.all([req1, req2, req3]);\ 54 | const [ json1, json2, json3 ] = await Promise.all([res1.json(), res2.json(), res3.json()]);\ 55 | console.log(json1); console.log(json2); console.log(json3);\ 56 | return Functions.encodeString(json1.message + json2.message + json3.message);`, 57 | }) 58 | 59 | const expected = { 60 | capturedTerminalOutput: 61 | '{ message: "Hello, world!" }\n{ message: "Hello, world!" }\n{ message: "Hello, world!" }\n', 62 | responseBytesHexstring: 63 | '0x48656c6c6f2c20776f726c642148656c6c6f2c20776f726c642148656c6c6f2c20776f726c6421', 64 | } 65 | 66 | expect(result).toEqual(expected) 67 | 68 | server.close() 69 | }) 70 | 71 | it('should handle script with type error', async () => { 72 | const result = await simulateScript({ 73 | source: 'const myString: string = 123; return Functions.encodeUint256(myString);', 74 | }) 75 | 76 | const expected = { 77 | capturedTerminalOutput: '', 78 | responseBytesHexstring: 79 | '0x000000000000000000000000000000000000000000000000000000000000007b', 80 | } 81 | 82 | expect(result).toEqual(expected) 83 | }) 84 | }) 85 | 86 | describe('handle errors during simulation', () => { 87 | it('should handle when HTTP request takes longer than max time', async () => { 88 | const server = createTestServerWithResponseDelay() 89 | const port = (server.address() as AddressInfo).port 90 | 91 | const result = await simulateScript({ 92 | source: `const response = await fetch('http://localhost:${port}'); const jsonResponse = await response.json(); console.log(jsonResponse); return Functions.encodeUint256(1);`, 93 | maxQueryDurationMs: 50, 94 | }) 95 | 96 | const expected = { 97 | capturedTerminalOutput: '{ error: "HTTP query exceeded time limit of 50ms" }\n', 98 | responseBytesHexstring: 99 | '0x0000000000000000000000000000000000000000000000000000000000000001', 100 | } 101 | 102 | expect(result).toEqual(expected) 103 | 104 | server.close() 105 | }) 106 | it('should handle when response size is exceeded', async () => { 107 | const result = await simulateScript({ 108 | source: "console.log('start'); return Functions.encodeString('0123456789012');", 109 | maxOnChainResponseBytes: 10, 110 | }) 111 | 112 | const expected = { 113 | capturedTerminalOutput: 'start\n', 114 | errorString: 'response >10 bytes', 115 | } 116 | 117 | expect(result).toEqual(expected) 118 | }) 119 | 120 | it('should handle when script throws error', async () => { 121 | const result = await simulateScript({ 122 | source: "throw new Error('test');", 123 | }) 124 | 125 | const expected = { 126 | capturedTerminalOutput: '', 127 | errorString: 'test', 128 | } 129 | 130 | expect(result).toEqual(expected) 131 | }) 132 | 133 | it('should handle when script throws string', async () => { 134 | const result = await simulateScript({ 135 | source: "throw 'test';", 136 | }) 137 | 138 | const expected = { 139 | capturedTerminalOutput: '', 140 | errorString: 'test', 141 | } 142 | 143 | expect(result).toEqual(expected) 144 | }) 145 | 146 | it('should handle when script throws unsupported value', async () => { 147 | const result = await simulateScript({ 148 | source: 'throw 123;', 149 | }) 150 | 151 | const expected = { 152 | capturedTerminalOutput: '', 153 | errorString: 'invalid value thrown of type number', 154 | } 155 | 156 | expect(result).toEqual(expected) 157 | }) 158 | 159 | it('should capture syntax error', async () => { 160 | const result = await simulateScript({ 161 | source: "console.log('start'); return Functions.encodeString(", 162 | }) 163 | 164 | expect(result.capturedTerminalOutput).toContain( 165 | "The module's source code could not be parsed", 166 | ) 167 | expect(result.errorString).toBe('syntax error, RAM exceeded, or other error') 168 | }) 169 | 170 | it('should capture incorrect return value', async () => { 171 | const result = await simulateScript({ 172 | source: "return 'invalid'", 173 | }) 174 | 175 | const expected = { 176 | capturedTerminalOutput: '', 177 | errorString: 'returned value not an ArrayBuffer or Uint8Array', 178 | } 179 | 180 | expect(result).toEqual(expected) 181 | }) 182 | 183 | it('should handle when no value is returned', async () => { 184 | const result = await simulateScript({ 185 | source: 'return', 186 | }) 187 | 188 | const expected = { 189 | capturedTerminalOutput: '', 190 | errorString: 'returned value not an ArrayBuffer or Uint8Array', 191 | } 192 | 193 | expect(result).toEqual(expected) 194 | }) 195 | 196 | it('should capture timeout error', async () => { 197 | const result = await simulateScript({ 198 | source: 'while (true) {}', 199 | maxExecutionTimeMs: 100, 200 | }) 201 | 202 | const expected = { 203 | capturedTerminalOutput: '', 204 | errorString: 'script runtime exceeded', 205 | } 206 | 207 | expect(result).toEqual(expected) 208 | }) 209 | 210 | it('should capture permissions error', async () => { 211 | const result = await simulateScript({ 212 | source: "Deno.openSync('test.txt')", 213 | maxExecutionTimeMs: 100, 214 | }) 215 | 216 | const expected = { 217 | capturedTerminalOutput: '', 218 | errorString: 'attempted access to blocked resource detected', 219 | } 220 | 221 | expect(result).toEqual(expected) 222 | }) 223 | }) 224 | 225 | describe('validation errors', () => { 226 | it('should throw error for invalid source', async () => { 227 | const result = simulateScript({ 228 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 229 | source: 123 as any, 230 | }) 231 | 232 | await expect(result).rejects.toThrow('source param is missing or invalid') 233 | }) 234 | 235 | it('should throw error for invalid secrets', async () => { 236 | const result = simulateScript({ 237 | source: 'return', 238 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 239 | secrets: { bad: 1 } as any, 240 | }) 241 | 242 | await expect(result).rejects.toThrow('secrets param not a string map') 243 | }) 244 | 245 | it('should throw error for invalid args', async () => { 246 | const result = simulateScript({ 247 | source: 'return', 248 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 249 | args: 123 as any, 250 | }) 251 | 252 | await expect(result).rejects.toThrow('args param not an array') 253 | }) 254 | 255 | it('should throw error when an element of args is not a string', async () => { 256 | const result = simulateScript({ 257 | source: 'return', 258 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 259 | args: [123] as any, 260 | }) 261 | 262 | await expect(result).rejects.toThrow('args param not a string array') 263 | }) 264 | 265 | it('should throw error for invalid bytesArgs', async () => { 266 | const result = simulateScript({ 267 | source: 'return', 268 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 269 | bytesArgs: 123 as any, 270 | }) 271 | 272 | await expect(result).rejects.toThrow('bytesArgs param not an array') 273 | }) 274 | 275 | it('should throw error when an element of bytesArgs is not a hex string', async () => { 276 | const result = simulateScript({ 277 | source: 'return', 278 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 279 | bytesArgs: ['invalid'] as any, 280 | }) 281 | 282 | await expect(result).rejects.toThrow('bytesArgs param contains invalid hex string') 283 | }) 284 | 285 | it('should allow 3rd party imports', async () => { 286 | const result = await simulateScript({ 287 | source: 288 | 'const { escape } = await import("https://deno.land/std/regexp/mod.ts"); return Functions.encodeString(escape("$hello*world?"));', 289 | }) 290 | 291 | expect(result.responseBytesHexstring).toEqual( 292 | `0x${Buffer.from('\\$hello\\*world\\?').toString('hex')}`, 293 | ) 294 | }) 295 | 296 | it('should allow NPM imports', async () => { 297 | const result = await simulateScript({ 298 | source: 299 | 'const { format } = await import("npm:date-fns"); return Functions.encodeString(format(new Date(), "yyyy-MM-dd"));', 300 | }) 301 | 302 | expect(Buffer.from(result.responseBytesHexstring?.slice(2) as string, 'hex').length).toEqual( 303 | 10, 304 | ) 305 | }) 306 | }) 307 | }) 308 | 309 | const createTestServer = (): http.Server => { 310 | const server = http.createServer((_, res) => { 311 | res.writeHead(200, { 'Content-Type': 'application/json' }) 312 | res.end(JSON.stringify({ message: 'Hello, world!' })) 313 | }) 314 | 315 | server.listen() 316 | 317 | return server 318 | } 319 | 320 | const createTestServerWithResponseDelay = (): http.Server => { 321 | const server = http.createServer((_, res) => { 322 | setTimeout(() => { 323 | res.writeHead(200, { 'Content-Type': 'application/json' }) 324 | res.end(JSON.stringify({ message: 'Hello, world!' })) 325 | }, 100) 326 | }) 327 | 328 | server.listen() 329 | 330 | return server 331 | } 332 | -------------------------------------------------------------------------------- /test/utils/contracts/FunctionsConsumer.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import {FunctionsClient} from "./@chainlink/contracts/src/v0.8/functions/dev/1_0_0/FunctionsClient.sol"; 5 | import {ConfirmedOwner} from "./@chainlink/contracts/src/v0.8/shared/access/ConfirmedOwner.sol"; 6 | import {FunctionsRequest} from "./@chainlink/contracts/src/v0.8/functions/dev/1_0_0/libraries/FunctionsRequest.sol"; 7 | 8 | /** 9 | * @title Chainlink Functions example on-demand consumer contract example 10 | */ 11 | contract FunctionsConsumer is FunctionsClient, ConfirmedOwner { 12 | using FunctionsRequest for FunctionsRequest.Request; 13 | 14 | bytes32 public donId; // DON ID for the Functions DON to which the requests are sent 15 | 16 | bytes32 public s_lastRequestId; 17 | bytes public s_lastResponse; 18 | bytes public s_lastError; 19 | 20 | constructor(address router, bytes32 _donId) FunctionsClient(router) ConfirmedOwner(msg.sender) { 21 | donId = _donId; 22 | } 23 | 24 | /** 25 | * @notice Set the DON ID 26 | * @param newDonId New DON ID 27 | */ 28 | function setDonId(bytes32 newDonId) external onlyOwner { 29 | donId = newDonId; 30 | } 31 | 32 | /** 33 | * @notice Triggers an on-demand Functions request 34 | * @param source JavaScript source code 35 | * @param secretsLocation Location of secrets (only Location.Remote & Location.DONHosted are supported) 36 | * @param encryptedSecretsReference Reference pointing to encrypted secrets 37 | * @param args String arguments passed into the source code and accessible via the global variable `args` 38 | * @param bytesArgs Bytes arguments passed into the source code and accessible via the global variable `bytesArgs` as hex strings 39 | * @param subscriptionId Subscription ID used to pay for request (FunctionsConsumer contract address must first be added to the subscription) 40 | * @param callbackGasLimit Maximum amount of gas used to call the inherited `handleOracleFulfillment` method 41 | */ 42 | function sendRequest( 43 | string calldata source, 44 | FunctionsRequest.Location secretsLocation, 45 | bytes calldata encryptedSecretsReference, 46 | string[] calldata args, 47 | bytes[] calldata bytesArgs, 48 | uint64 subscriptionId, 49 | uint32 callbackGasLimit 50 | ) external onlyOwner { 51 | FunctionsRequest.Request memory req; 52 | req.initializeRequest(FunctionsRequest.Location.Inline, FunctionsRequest.CodeLanguage.JavaScript, source); 53 | req.secretsLocation = secretsLocation; 54 | req.encryptedSecretsReference = encryptedSecretsReference; 55 | if (args.length > 0) { 56 | req.setArgs(args); 57 | } 58 | if (bytesArgs.length > 0) { 59 | req.setBytesArgs(bytesArgs); 60 | } 61 | s_lastRequestId = _sendRequest(req.encodeCBOR(), subscriptionId, callbackGasLimit, donId); 62 | } 63 | 64 | /** 65 | * @notice Triggers an on-demand Functions request that has been encoded off-chain 66 | * @param encodedRequest CBOR-encoded Functions request 67 | * @param subscriptionId Subscription ID used to pay for request (FunctionsConsumer contract address must first be added to the subscription) 68 | * @param callbackGasLimit Maximum amount of gas used to call the inherited `handleOracleFulfillment` method 69 | */ 70 | function sendEncodedRequest( 71 | bytes calldata encodedRequest, 72 | uint64 subscriptionId, 73 | uint32 callbackGasLimit 74 | ) external onlyOwner { 75 | s_lastRequestId = _sendRequest(encodedRequest, subscriptionId, callbackGasLimit, donId); 76 | } 77 | 78 | /** 79 | * @notice Store latest result/error 80 | * @param requestId The request ID, returned by sendRequest() 81 | * @param response Aggregated response from the user code 82 | * @param err Aggregated error from the user code or from the execution pipeline 83 | * Either response or error parameter will be set, but never both 84 | */ 85 | function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) internal override { 86 | s_lastResponse = response; 87 | s_lastError = err; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { startLocalFunctionsTestnet } from '../../src' 2 | import { ExampleFunctionsConsumerSource } from './contracts/FunctionsConsumerSource' 3 | 4 | import path from 'path' 5 | 6 | import { Wallet, providers, ContractFactory, utils } from 'ethers' 7 | 8 | import type { GetFunds } from '../../src' 9 | 10 | import type { Contract } from 'ethers' 11 | 12 | export const setupLocalTestnetFixture = async ( 13 | port: number, 14 | ): Promise<{ 15 | donId: string 16 | linkTokenContract: Contract 17 | linkTokenAddress: string 18 | functionsCoordinator: Contract 19 | functionsRouterAddress: string 20 | exampleConsumer: Contract 21 | exampleConsumerAddress: string 22 | close: () => Promise 23 | user_A: Wallet 24 | user_B_NoLINK: Wallet 25 | subFunder: Wallet 26 | getFunds: GetFunds 27 | }> => { 28 | const localFunctionsTestnet = await startLocalFunctionsTestnet( 29 | path.join(__dirname, 'testSimulationConfig.ts'), 30 | port, 31 | ) 32 | 33 | const provider = new providers.JsonRpcProvider(`http://127.0.0.1:${port}/`) 34 | const admin = new Wallet(localFunctionsTestnet.adminWallet.privateKey, provider) 35 | const functionsTestConsumerContractFactory = new ContractFactory( 36 | ExampleFunctionsConsumerSource.abi, 37 | ExampleFunctionsConsumerSource.bytecode, 38 | admin, 39 | ) 40 | const exampleConsumer = await functionsTestConsumerContractFactory 41 | .connect(admin) 42 | .deploy( 43 | localFunctionsTestnet.functionsRouterContract.address, 44 | utils.formatBytes32String(localFunctionsTestnet.donId), 45 | ) 46 | 47 | const [user_A, user_B_NoLINK, subFunder] = createTestWallets(port) 48 | 49 | const juelsAmount = BigInt(utils.parseUnits('100', 'ether').toString()) 50 | await localFunctionsTestnet.getFunds(user_A.address, { 51 | juelsAmount, 52 | }) 53 | await localFunctionsTestnet.getFunds(subFunder.address, { 54 | juelsAmount, 55 | }) 56 | 57 | return { 58 | donId: localFunctionsTestnet.donId, 59 | linkTokenContract: localFunctionsTestnet.linkTokenContract, 60 | linkTokenAddress: localFunctionsTestnet.linkTokenContract.address, 61 | functionsCoordinator: localFunctionsTestnet.functionsMockCoordinatorContract, 62 | functionsRouterAddress: localFunctionsTestnet.functionsRouterContract.address, 63 | exampleConsumer: exampleConsumer, 64 | exampleConsumerAddress: exampleConsumer.address, 65 | close: localFunctionsTestnet.close, 66 | user_A, 67 | user_B_NoLINK, 68 | subFunder, 69 | getFunds: localFunctionsTestnet.getFunds, 70 | } 71 | } 72 | 73 | const createTestWallets = (port = 8545): Wallet[] => { 74 | const wallets: Wallet[] = [] 75 | const provider = new providers.JsonRpcProvider(`http://127.0.0.1:${port}`) 76 | 77 | // these are hardcoded private keys provided by anvil. you can see these private keys in the console output if you simply run `anvil` 78 | // using these makes sure that these wallets are properly connected to Anvil local node 79 | // check https://book.getfoundry.sh/anvil/#getting-started for more details 80 | const privateKeys = [ 81 | '59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', 82 | '5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a', 83 | '7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6', 84 | ] 85 | 86 | for (const privateKey of privateKeys) { 87 | wallets.push(new Wallet(privateKey).connect(provider)) 88 | } 89 | 90 | return wallets 91 | } 92 | -------------------------------------------------------------------------------- /test/utils/testSimulationConfig.ts: -------------------------------------------------------------------------------- 1 | export const secrets = { test: 'hello world' } 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declarationMap": false, 6 | "outDir": "./dist" 7 | }, 8 | "include": ["src/**/*", "src/**/*.json"], 9 | "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts", "src/simulateScript/deno-sandbox/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "es2022", 5 | "module": "commonjs", 6 | "noEmit": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "skipLibCheck": true 15 | }, 16 | "include": ["src/**/*", "src/**/*.json", "test/**/*", "test/**/*.json"], 17 | "exclude": ["dist", "src/simulateScript/deno-sandbox/**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: path.resolve(__dirname, 'src/simulateScript/frontendSimulateScript.ts'), 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'frontendSimulateScript.bundle.js', 9 | library: { 10 | type: 'commonjs-static', 11 | }, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.ts$/, 17 | exclude: /node_modules/, 18 | use: { 19 | loader: 'babel-loader', 20 | options: { 21 | presets: [['@babel/preset-env', { targets: 'defaults' }], '@babel/preset-typescript'], 22 | }, 23 | }, 24 | }, 25 | ], 26 | }, 27 | externalsPresets: { 28 | // ignore built-in modules like path, fs, etc. - we trust browserify to handle these 29 | node: true, 30 | }, 31 | resolve: { 32 | extensions: ['.ts', '.js'], 33 | }, 34 | } 35 | --------------------------------------------------------------------------------