diff --git a/.changeset/honest-rockets-itch.md b/.changeset/honest-rockets-itch.md new file mode 100644 index 0000000..8eceed8 --- /dev/null +++ b/.changeset/honest-rockets-itch.md @@ -0,0 +1,5 @@ +--- +'@chainlink/functions-toolkit': minor +--- + +Added localFunctionsTestnet diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8db3b4e..7fb7f6f 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -27,7 +27,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Run npm ci - run: npm ci + run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional - name: Setup project run: npm run build diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml index 5807b37..a18dfee 100644 --- a/.github/workflows/prettier.yaml +++ b/.github/workflows/prettier.yaml @@ -17,7 +17,7 @@ jobs: node-version: 18 - name: Install dependencies - run: npm ci + run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional - name: Run Prettier check run: npx prettier --check . diff --git a/.github/workflows/test-converage.yaml b/.github/workflows/test-converage.yaml index a9a9a6a..dd46f9d 100644 --- a/.github/workflows/test-converage.yaml +++ b/.github/workflows/test-converage.yaml @@ -19,7 +19,7 @@ jobs: node-version: 18 - name: Install dependencies - run: npm ci + run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional - name: Setup Deno uses: denolib/setup-deno@v2 diff --git a/.github/workflows/test-package.yaml b/.github/workflows/test-package.yaml index 333fcd4..6a1600b 100644 --- a/.github/workflows/test-package.yaml +++ b/.github/workflows/test-package.yaml @@ -19,7 +19,7 @@ jobs: node-version: 18 - name: Install dependencies - run: npm ci + run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency --no-optional - name: Setup Deno uses: denolib/setup-deno@v2 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1ef86c8..78127ab 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,7 +19,7 @@ jobs: node-version: 18 - name: Install dependencies - run: npm ci + run: npm install # npm install instead of npm ci is used to prevent unsupported platform errors due to the fsevents sub-dependency - name: Setup Deno uses: denolib/setup-deno@v2 diff --git a/README.md b/README.md index a20f86c..c9a85d9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ The typical subscriptions-related operations are - [Functions Response Listener](#functions-response-listener) - [Functions Utilities](#functions-utilities) - [Local Functions Simulator](#local-functions-simulator) + - [Local Functions Testnet](#local-functions-testnet) - [Decoding Response Bytes](#decoding-response-bytes) - [Storing Encrypted Secrets in Gists](#storing-encrypted-secrets-in-gists) - [Building Functions Request CBOR Bytes](#building-functions-request-cbor-bytes) @@ -535,8 +536,61 @@ const result = await simulateScript({ } ``` +**_NOTE:_** When running `simulateScript`, depending on your security settings, you may get a popup asking if you would like to accept incoming network connections. You can safely ignore this popup and it should disappear when the simulation is complete. + **_NOTE:_** The `simulateScript` function is a debugging tool and hence is not a perfect representation of the actual Chainlink oracle execution environment. Therefore, it is important to make a Functions request on a supported testnet blockchain before mainnet usage. +### Local Functions Testnet + +For debugging smart contracts and the end-to-end request flow on your local machine, you can use the `localFunctionsTestnet` function. This creates a local testnet RPC node with a mock Chainlink Functions contracts. You can then deploy your own Functions consumer contract to this local network, create and manage subscriptions, and send requests. Request processing will simulate the behavior of an actual DON where the request is executed 4 times and the discrete median response is transmitted back to the consumer contract. (Note that Functions uses the following calculation to select the discrete median response: `const medianResponse = responses[responses.length - 1) / 2]`). + +The `localFunctionsTestnet` function takes the following values as arguments. + +``` +const localFunctionsTestnet = await startLocalFunctionsTestnet( + simulationConfigPath?: string // Absolute path to config file which exports simulation config parameters + options?: ServerOptions, // Ganache server options + port?: number, // Defaults to 8545 +) +``` + +Observe that `localFunctionsTestnet` takes in a `simulationConfigPath` string as an optional argument. The primary reason for this is because the local testnet does not have the ability to access or decrypt encrypted secrets provided within request transactions. Instead, you can export an object named `secrets` from a TypeScript or JavaScript file and provide the absolute path to that file as the `simulationConfigPath` argument. When the JavaScript code is executed during the request, secrets specified in that file will be made accessible within the JavaScript code regardless of the `secretsLocation` or `encryptedSecretsReference` values sent in the request transaction. This config file can also contain other simulation config parameters. An example of this config file is shown below. + +``` +export const secrets: { test: 'hello world' } // `secrets` object which can be accessed by the JavaScript code during request execution (can only contain string values) +export const maxOnChainResponseBytes = 256 // Maximum size of the returned value in bytes (defaults to 256) +export const maxExecutionTimeMs = 10000 // Maximum execution duration (defaults to 10_000ms) +export const maxMemoryUsageMb = 128 // Maximum RAM usage (defaults to 128mb) +export const numAllowedQueries = 5 // Maximum number of HTTP requests (defaults to 5) +export const maxQueryDurationMs = 9000// Maximum duration of each HTTP request (defaults to 9_000ms) +export const maxQueryUrlLength = 2048 // Maximum HTTP request URL length (defaults to 2048) +export const maxQueryRequestBytes = 2048 // Maximum size of outgoing HTTP request payload (defaults to 2048 == 2 KB) +export const maxQueryResponseBytes = 2097152 // Maximum size of incoming HTTP response payload (defaults to 2_097_152 == 2 MB) +``` + +`localFunctionsTestnet` returns a promise which resolves to the following type. + +``` +{ + server: Server // Ganache server + adminWallet: { address: string, privateKey: string } // Funded admin wallet + getFunds: (address: string, { weiAmount, juelsAmount }: { weiAmount?: BigInt | string; juelsAmount?: BigInt | string }) => Promise // Method which can be called to send funds to any address + close: () => Promise // Method to close the server + donId: string // DON ID for simulated DON + // The following values are all Ethers.js contract types: https://docs.ethers.org/v5/api/contract/contract/ + linkTokenContract: Contract // Mock LINK token contract + functionsRouterContract: Contract // Mock FunctionsRouter contract +} +``` + +Now you can connect to the local Functions testnet RPC node with your preferred blockchain tooling, deploy a FunctionsConsumer contract, instantiate and initialize the`SubscriptionManager`, create, add the consumer contract and fund the subscription, send requests, and use the `ResponseListener` to listen for responses all on your machine. + +**_NOTE:_** When simulating request executions, depending on your security settings, you may get multiple popups asking if you would like to accept incoming network connections. You can safely ignore these popups and they should disappear when the executions are complete. + +**_NOTE:_** Cost estimates and other configuration values may differ significantly from actual values on live testnet or mainnet chains. + +**_NOTE:_** The `localFunctionsTestnet` function is a debugging tool and hence is not a perfect representation of the actual Chainlink oracle execution environment. Therefore, it is important to make a Functions request on a supported testnet blockchain before mainnet usage. + ### Decoding Response Bytes On-chain responses are encoded as Solidity `bytes` which are most frequently displayed as hex strings. However, these hex strings often need to be decoded into a useable type. In order to decode hex strings into human-readable values, this package provides the `decodeResult` function. Currently, the `decodeResult` function supports decoding hex strings into `uint256`, `int256` or `string` values. diff --git a/jest.config.js b/jest.config.js index 7f8f203..928554b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/test/**/*.test.ts'], - testTimeout: 240 * 1000, + testTimeout: 2 * 60 * 1000, coverageReporters: ['html'], collectCoverageFrom: ['src/**/*.ts', '!src/test/*.ts', '!src/simulateScript/deno-sandbox/*.ts'], diff --git a/package-lock.json b/package-lock.json index f6294cb..2d769de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chainlink/functions-toolkit", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chainlink/functions-toolkit", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "dependencies": { "axios": "^1.4.0", @@ -6955,7 +6955,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -8662,9 +8661,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "extraneous": true, "os": [ - "darwin", - "linux", - "win32" + "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" diff --git a/src/localFunctionsTestnet.ts b/src/localFunctionsTestnet.ts index 39c4be4..479c93e 100644 --- a/src/localFunctionsTestnet.ts +++ b/src/localFunctionsTestnet.ts @@ -13,6 +13,7 @@ import { callReportGasLimit, simulatedSecretsKeys, simulatedTransmitters, + numberOfSimulatedNodeExecutions, } from './simulationConfig' import { LinkTokenSource, @@ -20,56 +21,25 @@ import { FunctionsRouterSource, FunctionsCoordinatorTestHelperSource, TermsOfServiceAllowListSource, - FunctionsClientExampleSource, } from './v1_contract_sources' -import type { Server, Ethereum } from 'ganache' - -import type { FunctionsRequestParams, RequestCommitment } from './types' - -export interface RequestEventData { - requestId: string - requestingContract: string - requestInitiator: string - subscriptionId: any - subscriptionOwner: string - data: string - dataVersion: number - flags: string - callbackGasLimit: number - commitment: RequestCommitment -} +import type { ServerOptions } from 'ganache' -interface FunctionsContracts { - linkToken: Contract - router: Contract - mockCoordinator: Contract - exampleClient: Contract -} +import type { + FunctionsRequestParams, + RequestCommitment, + LocalFunctionsTestnet, + GetFunds, + FunctionsContracts, + RequestEventData, +} from './types' export const startLocalFunctionsTestnet = async ( + simulationConfigPath?: string, + options?: ServerOptions, port = 8545, -): Promise< - { - server: Server - adminWallet: { - address: string - privateKey: string - } - getFunds: ( - address: string, - { ethAmount, linkAmount }: { ethAmount: number; linkAmount: number }, - ) => Promise - close: () => Promise - } & FunctionsContracts -> => { - const server = Ganache.server({ - logging: { - debug: false, - verbose: false, - quiet: true, - }, - }) +): Promise => { + const server = Ganache.server(options) server.listen(port, 'localhost', (err: Error | null) => { if (err) { @@ -87,7 +57,7 @@ export const startLocalFunctionsTestnet = async ( const contracts = await deployFunctionsOracle(admin) - contracts.mockCoordinator.on( + contracts.functionsMockCoordinatorContract.on( 'OracleRequest', ( requestId, @@ -113,29 +83,46 @@ export const startLocalFunctionsTestnet = async ( callbackGasLimit, commitment, } - handleOracleRequest(requestEvent, contracts.mockCoordinator, admin) + handleOracleRequest( + requestEvent, + contracts.functionsMockCoordinatorContract, + admin, + simulationConfigPath, + ) }, ) - const getFunds = async ( - address: string, - { ethAmount, linkAmount }: { ethAmount: number; linkAmount: number }, - ): Promise => { - const weiAmount = utils.parseEther(ethAmount.toString()) - const juelsAmount = utils.parseEther(linkAmount.toString()) - + const getFunds: GetFunds = async (address, { weiAmount, juelsAmount }) => { + if (!juelsAmount) { + juelsAmount = BigInt(0) + } + if (!weiAmount) { + weiAmount = BigInt(0) + } + if (typeof weiAmount !== 'string' && typeof weiAmount !== 'bigint') { + throw Error(`weiAmount must be a BigInt or string, got ${typeof weiAmount}`) + } + if (typeof juelsAmount !== 'string' && typeof juelsAmount !== 'bigint') { + throw Error(`juelsAmount must be a BigInt or string, got ${typeof juelsAmount}`) + } + weiAmount = BigInt(weiAmount) + juelsAmount = BigInt(juelsAmount) const ethTx = await admin.sendTransaction({ to: address, - value: weiAmount, + value: weiAmount.toString(), }) - const linkTx = await contracts.linkToken.connect(admin).transfer(address, juelsAmount) + const linkTx = await contracts.linkTokenContract.connect(admin).transfer(address, juelsAmount) await ethTx.wait(1) await linkTx.wait(1) - console.log(`Sent ${ethAmount} ETH and ${linkAmount} LINK to ${address}`) + console.log( + `Sent ${utils.formatEther(weiAmount.toString())} ETH and ${utils.formatEther( + juelsAmount.toString(), + )} LINK to ${address}`, + ) } const close = async (): Promise => { - contracts.mockCoordinator.removeAllListeners('OracleRequest') + contracts.functionsMockCoordinatorContract.removeAllListeners('OracleRequest') await server.close() } @@ -155,14 +142,11 @@ const handleOracleRequest = async ( requestEventData: RequestEventData, mockCoordinator: Contract, admin: Wallet, + simulationConfigPath?: string, ) => { - const requestData = await constructRequestDataObject(requestEventData.data) - const response = await simulateScript({ - source: requestData.source, - secrets: {}, // TODO: Decrypt secrets - args: requestData.args, - // TODO: Support bytes args - }) + const requestData = await buildRequestObject(requestEventData.data) + const response = await simulateDONExecution(requestData, simulationConfigPath) + const errorHexstring = response.errorString ? '0x' + Buffer.from(response.errorString.toString()).toString('hex') : undefined @@ -179,6 +163,86 @@ const handleOracleRequest = async ( await reportTx.wait(1) } +const simulateDONExecution = async ( + requestData: FunctionsRequestParams, + simulationConfigPath?: string, +): Promise<{ responseBytesHexstring?: string; errorString?: string }> => { + const simulationConfig = simulationConfigPath ? require(simulationConfigPath) : {} + + // Perform the simulation numberOfSimulatedNodeExecution times + const simulations = [...Array(numberOfSimulatedNodeExecutions)].map(() => + simulateScript({ + source: requestData.source, + secrets: simulationConfig.secrets, // Secrets are taken from simulationConfig, not request data included in transaction + args: requestData.args, + bytesArgs: requestData.bytesArgs, + maxOnChainResponseBytes: simulationConfig.maxOnChainResponseBytes, + maxExecutionTimeMs: simulationConfig.maxExecutionTimeMs, + maxMemoryUsageMb: simulationConfig.maxMemoryUsageMb, + numAllowedQueries: simulationConfig.numAllowedQueries, + maxQueryDurationMs: simulationConfig.maxQueryDurationMs, + maxQueryUrlLength: simulationConfig.maxQueryUrlLength, + maxQueryRequestBytes: simulationConfig.maxQueryRequestBytes, + maxQueryResponseBytes: simulationConfig.maxQueryResponseBytes, + }), + ) + const responses = await Promise.all(simulations) + + const successfulResponses = responses.filter(response => response.errorString === undefined) + const errorResponses = responses.filter(response => response.errorString !== undefined) + + if (successfulResponses.length > errorResponses.length) { + return { + responseBytesHexstring: aggregateMedian( + successfulResponses.map(response => response.responseBytesHexstring!), + ), + } + } else { + return { + errorString: aggregateModeString(errorResponses.map(response => response.errorString!)), + } + } +} + +const aggregateMedian = (responses: string[]): string => { + const bufResponses = responses.map(response => Buffer.from(response.slice(2), 'hex')) + + bufResponses.sort((a, b) => { + if (a.length !== b.length) { + return a.length - b.length + } + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) { + return a[i] - b[i] + } + } + return 0 + }) + + return '0x' + bufResponses[Math.floor((bufResponses.length - 1) / 2)].toString('hex') +} + +const aggregateModeString = (items: string[]): string => { + const counts = new Map() + + for (const str of items) { + const existingCount = counts.get(str) || 0 + counts.set(str, existingCount + 1) + } + + let modeString = items[0] + let maxCount = counts.get(modeString) || 0 + + for (const [str, count] of counts.entries()) { + if (count > maxCount) { + maxCount = count + modeString = str + } + } + + return modeString +} + const encodeReport = ( requestId: string, commitment: RequestCommitment, @@ -220,37 +284,42 @@ const encodeReport = ( return encodedReport } -const constructRequestDataObject = async (requestData: string): Promise => { - const decodedRequestData = await cbor.decodeAll(Buffer.from(requestData.slice(2), 'hex')) - const requestDataObject = {} as FunctionsRequestParams +const buildRequestObject = async ( + requestDataHexString: string, +): Promise => { + const decodedRequestData = await cbor.decodeAll(Buffer.from(requestDataHexString.slice(2), 'hex')) + const requestDataObject = {} as FunctionsRequestParams + // The decoded request data is an array of alternating keys and values, therefore we can iterate over it in steps of 2 for (let i = 0; i < decodedRequestData.length - 1; i += 2) { - const elem = decodedRequestData[i] - // TODO: support encrypted secrets & bytesArgs - switch (elem) { + const requestDataKey = decodedRequestData[i] + const requestDataValue = decodedRequestData[i + 1] + switch (requestDataKey) { case 'codeLocation': - requestDataObject.codeLocation = decodedRequestData[i + 1] + requestDataObject.codeLocation = requestDataValue + break + case 'secretsLocation': + // Unused as secrets provided as an argument to startLocalFunctionsTestnet() are used instead break - // case 'secretsLocation': - // requestDataObject.secretsLocation = decodedRequestData[i + 1] - // break case 'language': - requestDataObject.codeLanguage = decodedRequestData[i + 1] + requestDataObject.codeLanguage = requestDataValue break case 'source': - requestDataObject.source = decodedRequestData[i + 1] + requestDataObject.source = requestDataValue + break + case 'secrets': + // Unused as secrets provided as an argument to startLocalFunctionsTestnet() are used instead break - // case 'encryptedSecretsReference': - // requestDataObject.encryptedSecretsReference = decodedRequestData[i + 1] - // break case 'args': - requestDataObject.args = decodedRequestData[i + 1] + requestDataObject.args = requestDataValue break - // case 'bytesArgs': - // requestDataObject.bytesArgs = decodedRequestData[i + 1] - // break - // default: - // throw Error(`Invalid request data key ${elem}`) + case 'bytesArgs': + requestDataObject.bytesArgs = requestDataValue.map((bytesArg: Buffer) => { + return '0x' + bytesArg.toString('hex') + }) + break + default: + // Ignore unknown keys } } @@ -299,13 +368,6 @@ export const deployFunctionsOracle = async (deployer: Wallet): Promise Promise + +export type LocalFunctionsTestnet = { + server: Server + adminWallet: { + address: string + privateKey: string + } + getFunds: GetFunds + close: () => Promise +} & FunctionsContracts diff --git a/test/integration/ResponseListener.test.ts b/test/integration/ResponseListener.test.ts index 2e8f9b8..3e58772 100644 --- a/test/integration/ResponseListener.test.ts +++ b/test/integration/ResponseListener.test.ts @@ -4,10 +4,9 @@ import { FunctionsResponse, SubscriptionManager, ResponseListener, - startLocalFunctionsTestnet, simulatedDonId, } from '../../src' -import { createTestWallets } from '../utils' +import { setupLocalTestnetFixture } from '../utils' import { Contract, Wallet, utils } from 'ethers' @@ -16,28 +15,15 @@ describe('Functions toolkit classes', () => { let functionsRouterAddress: string let exampleClient: Contract let close: () => Promise - let allowlistedUser_A: Wallet beforeAll(async () => { - const port = 9501 - const localFunctionsTestnet = await startLocalFunctionsTestnet(port) - - linkTokenAddress = localFunctionsTestnet.linkToken.address - functionsRouterAddress = localFunctionsTestnet.router.address - exampleClient = localFunctionsTestnet.exampleClient - close = localFunctionsTestnet.close - - const [admin, walletA, walletB, walletC, _] = createTestWallets( - localFunctionsTestnet.server, - port, - ) - allowlistedUser_A = walletA - - await localFunctionsTestnet.getFunds(allowlistedUser_A.address, { - ethAmount: 0, - linkAmount: 100, - }) + const testSetup = await setupLocalTestnetFixture(8002) + linkTokenAddress = testSetup.linkTokenAddress + functionsRouterAddress = testSetup.functionsRouterAddress + exampleClient = testSetup.exampleConsumer + close = testSetup.close + allowlistedUser_A = testSetup.user_A }) afterAll(async () => { @@ -86,7 +72,7 @@ describe('Functions toolkit classes', () => { utils.formatBytes32String(simulatedDonId), ) - const succReq = await succReqTx.wait(1) + const succReq = await succReqTx.wait() const succRequestId = succReq.events[0].topics[1] const succResponse = await functionsListener.listenForResponse(succRequestId) diff --git a/test/integration/integration.test.ts b/test/integration/integration.test.ts index 45e2aec..8b109f8 100644 --- a/test/integration/integration.test.ts +++ b/test/integration/integration.test.ts @@ -2,13 +2,12 @@ import EthCrypto from 'eth-crypto' import { SubscriptionManager, SecretsManager, - startLocalFunctionsTestnet, RequestCommitment, simulatedDonId, simulatedSecretsKeys, } from '../../src' import { mockOffchainSecretsEndpoints, mockGatewayUrl } from './apiFixture' -import { createTestWallets } from '../utils' +import { setupLocalTestnetFixture } from '../utils' import { BigNumber, Contract, Wallet, utils } from 'ethers' @@ -21,36 +20,23 @@ describe('Functions toolkit classes', () => { let exampleClient: Contract let consumerAddress: string let close: () => Promise - let allowlistedUser_A: Wallet let allowlistedUser_B_NoLINK: Wallet - // let unallowlistedUser: Wallet let subFunder_A: Wallet beforeAll(async () => { - const localFunctionsTestnet = await startLocalFunctionsTestnet() - - linkTokenContract = localFunctionsTestnet.linkToken - linkTokenAddress = localFunctionsTestnet.linkToken.address - functionsRouterAddress = localFunctionsTestnet.router.address - functionsCoordinator = localFunctionsTestnet.mockCoordinator - exampleClient = localFunctionsTestnet.exampleClient - consumerAddress = localFunctionsTestnet.exampleClient.address - close = localFunctionsTestnet.close - - const [admin, walletA, walletB, walletC, _] = createTestWallets(localFunctionsTestnet.server) - allowlistedUser_A = walletA - allowlistedUser_B_NoLINK = walletB - subFunder_A = walletC - - await localFunctionsTestnet.getFunds(allowlistedUser_A.address, { - ethAmount: 0, - linkAmount: 100, - }) - await localFunctionsTestnet.getFunds(subFunder_A.address, { - ethAmount: 0, - linkAmount: 100, - }) + const testSetup = await setupLocalTestnetFixture(8001) + donId = testSetup.donId + linkTokenContract = testSetup.linkTokenContract + linkTokenAddress = testSetup.linkTokenAddress + functionsCoordinator = testSetup.functionsCoordinator + functionsRouterAddress = testSetup.functionsRouterAddress + exampleClient = testSetup.exampleConsumer + consumerAddress = testSetup.exampleConsumerAddress + close = testSetup.close + allowlistedUser_A = testSetup.user_A + allowlistedUser_B_NoLINK = testSetup.user_B_NoLINK + subFunder_A = testSetup.subFunder }) afterAll(async () => { diff --git a/test/integration/localFunctionsTestnet.test.ts b/test/integration/localFunctionsTestnet.test.ts new file mode 100644 index 0000000..d0248a5 --- /dev/null +++ b/test/integration/localFunctionsTestnet.test.ts @@ -0,0 +1,197 @@ +import { + SubscriptionManager, + simulatedDonId, + decodeResult, + ResponseListener, + ReturnType, +} from '../../src' +import { setupLocalTestnetFixture } from '../utils' + +import { utils } from 'ethers' + +import type { GetFunds } from '../../src' + +import type { Contract, Wallet } from 'ethers' + +describe('Local Functions Testnet', () => { + let linkTokenAddress: string + let functionsRouterAddress: string + let exampleClient: Contract + let close: () => Promise + let allowlistedUser_A: Wallet + let getFunds: GetFunds + + beforeAll(async () => { + const testSetup = await setupLocalTestnetFixture(8003) + linkTokenAddress = testSetup.linkTokenAddress + functionsRouterAddress = testSetup.functionsRouterAddress + exampleClient = testSetup.exampleConsumer + close = testSetup.close + allowlistedUser_A = testSetup.user_A + getFunds = testSetup.getFunds + }) + + afterAll(async () => { + await close() + }) + + it('Successfully fulfills a request', async () => { + const subscriptionManager = new SubscriptionManager({ + signer: allowlistedUser_A, + linkTokenAddress, + functionsRouterAddress, + }) + await subscriptionManager.initialize() + + const subscriptionId = await subscriptionManager.createSubscription() + await subscriptionManager.fundSubscription({ + juelsAmount: utils.parseUnits('1', 'ether').toString(), + subscriptionId, + }) + await subscriptionManager.addConsumer({ + subscriptionId, + consumerAddress: exampleClient.address, + txOptions: { + confirmations: 1, + }, + }) + + const functionsListener = new ResponseListener({ + provider: allowlistedUser_A.provider, + functionsRouterAddress, + }) + + const reqTx = await exampleClient.sendRequest( + { + codeLocation: 0, + secretsLocation: 1, + language: 0, + source: + 'return Functions.encodeString(secrets.test + " " + args[0] + " " + args[1] + bytesArgs[0] + bytesArgs[1])', + encryptedSecretsReference: '0xabcd', + requestSignature: [], + args: ['hello', 'world'], + bytesArgs: ['0x1234', '0x5678'], + }, + subscriptionId, + utils.formatBytes32String(simulatedDonId), + ) + + const req = await reqTx.wait() + const requestId = req.events[0].topics[1] + const response = await functionsListener.listenForResponse(requestId) + + const responseString = decodeResult(response.responseBytesHexstring, ReturnType.string) + expect(responseString).toBe('hello world hello world0x12340x5678') + }) + + it('Successfully aggregates a random number', async () => { + const subscriptionManager = new SubscriptionManager({ + signer: allowlistedUser_A, + linkTokenAddress, + functionsRouterAddress, + }) + await subscriptionManager.initialize() + + const subscriptionId = await subscriptionManager.createSubscription() + await subscriptionManager.fundSubscription({ + juelsAmount: utils.parseUnits('1', 'ether').toString(), + subscriptionId, + }) + await subscriptionManager.addConsumer({ + subscriptionId, + consumerAddress: exampleClient.address, + txOptions: { + confirmations: 1, + }, + }) + + const functionsListener = new ResponseListener({ + provider: allowlistedUser_A.provider, + functionsRouterAddress, + }) + + const reqTx = await exampleClient.sendRequest( + { + codeLocation: 0, + secretsLocation: 1, + language: 0, + source: 'return Functions.encodeUint256(Math.floor((Math.random() + 0.1) * 1_000_000_000))', + encryptedSecretsReference: '0xabcd', + requestSignature: [], + args: ['hello', 'world'], + bytesArgs: ['0x1234', '0x5678'], + }, + subscriptionId, + utils.formatBytes32String(simulatedDonId), + ) + + const req = await reqTx.wait() + const requestId = req.events[0].topics[1] + const response = await functionsListener.listenForResponse(requestId) + + expect(response.responseBytesHexstring.length).toBeGreaterThan(2) + }) + + it('Successfully aggregates a random error', async () => { + const subscriptionManager = new SubscriptionManager({ + signer: allowlistedUser_A, + linkTokenAddress, + functionsRouterAddress, + }) + await subscriptionManager.initialize() + + const subscriptionId = await subscriptionManager.createSubscription() + await subscriptionManager.fundSubscription({ + juelsAmount: utils.parseUnits('1', 'ether').toString(), + subscriptionId, + }) + await subscriptionManager.addConsumer({ + subscriptionId, + consumerAddress: exampleClient.address, + txOptions: { + confirmations: 1, + }, + }) + + const functionsListener = new ResponseListener({ + provider: allowlistedUser_A.provider, + functionsRouterAddress, + }) + + const reqTx = await exampleClient.sendRequest( + { + codeLocation: 0, + secretsLocation: 1, + language: 0, + source: 'throw Error(`${Math.floor((Math.random() + 0.1) * 100)}`)', + encryptedSecretsReference: '0xabcd', + requestSignature: [], + args: ['hello', 'world'], + bytesArgs: ['0x1234', '0x5678'], + }, + subscriptionId, + utils.formatBytes32String(simulatedDonId), + ) + + const req = await reqTx.wait() + const requestId = req.events[0].topics[1] + const response = await functionsListener.listenForResponse(requestId) + + expect(parseInt(response.errorString)).toBeGreaterThan(0) + }) + + it('getFunds throws error for invalid weiAmount', async () => { + expect(async () => { + // @ts-ignore + await getFunds('0xc0ffee254729296a45a3885639AC7E10F9d54979', { weiAmount: 1 }) + }).rejects.toThrow(/weiAmount must be a BigInt or string/) + }) + + it('getFunds throws error for invalid juelsAmount', async () => { + expect(async () => { + // @ts-ignore + await getFunds('0xc0ffee254729296a45a3885639AC7E10F9d54979', { juelsAmount: 1 }) + }).rejects.toThrow(/juelsAmount must be a BigInt or string/) + }) +}) diff --git a/test/unit/simulateScript.test.ts b/test/unit/simulateScript.test.ts index 51d0ca3..7a9df2f 100644 --- a/test/unit/simulateScript.test.ts +++ b/test/unit/simulateScript.test.ts @@ -28,14 +28,10 @@ describe('simulateScript', () => { const server = createTestServer() const port = (server.address() as AddressInfo).port - console.log(port) - const result = await simulateScript({ source: `const response = await fetch('http://localhost:${port}'); const jsonResponse = await response.json(); console.log(jsonResponse); return Functions.encodeString(jsonResponse.message);`, }) - console.log(result) - const expected = { capturedTerminalOutput: '{ message: "Hello, world!" }\n', responseBytesHexstring: '0x48656c6c6f2c20776f726c6421', diff --git a/test/utils/index.ts b/test/utils/index.ts index 55f61d4..762f8e2 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -1,9 +1,85 @@ -import { Wallet, providers } from 'ethers' +import { startLocalFunctionsTestnet } from '../../src' +import { FunctionsClientExampleSource } from '../../src/v1_contract_sources' + +import path from 'path' + +import { Wallet, providers, ContractFactory, utils } from 'ethers' + +import type { GetFunds } from '../../src' + +import type { Contract } from 'ethers' import type { Server } from 'ganache' -export const createTestWallets = (server: Server, port = 8545): Wallet[] => { +export const setupLocalTestnetFixture = async ( + port: number, +): Promise<{ + donId: string + linkTokenContract: Contract + linkTokenAddress: string + functionsCoordinator: Contract + functionsRouterAddress: string + exampleConsumer: Contract + exampleConsumerAddress: string + close: () => Promise + user_A: Wallet + user_B_NoLINK: Wallet + subFunder: Wallet + getFunds: GetFunds +}> => { + const localFunctionsTestnet = await startLocalFunctionsTestnet( + path.join(__dirname, 'testSimulationConfig.ts'), + { + logging: { + debug: false, + verbose: false, + quiet: true, + }, + }, + port, + ) + + const provider = new providers.JsonRpcProvider(`http://localhost:${port}/`) + const admin = new Wallet(localFunctionsTestnet.adminWallet.privateKey, provider) + const functionsTestConsumerContractFactory = new ContractFactory( + FunctionsClientExampleSource.abi, + FunctionsClientExampleSource.bytecode, + admin, + ) + const exampleConsumer = await functionsTestConsumerContractFactory + .connect(admin) + .deploy(localFunctionsTestnet.functionsRouterContract.address) + + const [_admin, user_A, user_B_NoLINK, subFunder, _] = createTestWallets( + localFunctionsTestnet.server, + port, + ) + + const juelsAmount = BigInt(utils.parseUnits('100', 'ether').toString()) + await localFunctionsTestnet.getFunds(user_A.address, { + juelsAmount, + }) + await localFunctionsTestnet.getFunds(subFunder.address, { + juelsAmount, + }) + + return { + donId: localFunctionsTestnet.donId, + linkTokenContract: localFunctionsTestnet.linkTokenContract, + linkTokenAddress: localFunctionsTestnet.linkTokenContract.address, + functionsCoordinator: localFunctionsTestnet.functionsMockCoordinatorContract, + functionsRouterAddress: localFunctionsTestnet.functionsRouterContract.address, + exampleConsumer: exampleConsumer, + exampleConsumerAddress: exampleConsumer.address, + close: localFunctionsTestnet.close, + user_A, + user_B_NoLINK, + subFunder, + getFunds: localFunctionsTestnet.getFunds, + } +} + +const createTestWallets = (server: Server, port = 8545): Wallet[] => { const accounts = server.provider.getInitialAccounts() - const [addr0, addr1, addr2, addr3, addr4, addr5] = Object.keys(accounts) const wallets: Wallet[] = [] const provider = new providers.JsonRpcProvider(`http://localhost:${port}`) diff --git a/test/utils/testSimulationConfig.ts b/test/utils/testSimulationConfig.ts new file mode 100644 index 0000000..0ddd607 --- /dev/null +++ b/test/utils/testSimulationConfig.ts @@ -0,0 +1 @@ +export const secrets = { test: 'hello world' }