├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── README.md ├── jest.config.js ├── package.json ├── rollup.config.js ├── scripts └── publish.sh ├── src ├── ContractCache.ts ├── Tightbeam.ts ├── __mocks__ │ ├── abi.ts │ ├── abi2.ts │ └── ethers.ts ├── __tests__ │ ├── ContractCache.test.ts │ └── Tightbeam.test.ts ├── abis │ ├── AbiDefinition.ts │ ├── AbiMapping.ts │ ├── __mocks__ │ │ └── AbiMapping.ts │ ├── __tests__ │ │ ├── AbiDefinition.test.ts │ │ └── AbiMapping.test.ts │ └── index.ts ├── index.ts ├── multicall │ ├── Call.ts │ ├── MulticallBatch.ts │ ├── MulticallExecutor.ts │ ├── MulticallLink.ts │ ├── __mocks__ │ │ ├── decodeCalls.ts │ │ └── encodeCalls.ts │ ├── __tests__ │ │ ├── MulticallBatch.test.ts │ │ ├── MulticallExecutor.test.ts │ │ ├── countCalls.test.ts │ │ ├── decodeCalls.test.ts │ │ └── encodeCalls.test.ts │ ├── countCalls.ts │ ├── decodeCalls.ts │ └── encodeCalls.ts ├── queries │ ├── __tests__ │ │ └── sendTransactionMutation.test.ts │ ├── accountQuery.ts │ ├── allTransactionsQuery.ts │ ├── blockQuery.ts │ ├── cachedTransactionsQuery.ts │ ├── contractQuery.ts │ ├── index.ts │ ├── networkQuery.ts │ ├── sendTransactionMutation.ts │ ├── transactionFragment.ts │ └── transactionsQuery.ts ├── resolvers │ ├── __tests__ │ │ ├── bindMutationResolvers.test.ts │ │ ├── bindQueryResolvers.test.ts │ │ └── bindResolvers.test.ts │ ├── bindMutationResolvers.ts │ ├── bindQueryResolvers.ts │ ├── bindResolvers.ts │ ├── index.ts │ ├── mutations │ │ ├── __tests__ │ │ │ └── sendTransactionResolver.test.ts │ │ ├── index.ts │ │ └── sendTransactionResolver.ts │ └── queries │ │ ├── __mocks__ │ │ └── index.ts │ │ ├── __tests__ │ │ ├── accountResolver.test.ts │ │ ├── blockResolver.test.ts │ │ ├── callResolver.test.ts │ │ ├── contractResolver.test.ts │ │ ├── networkResolvers.test.ts │ │ └── pastEventsResolver.test.ts │ │ ├── accountResolver.ts │ │ ├── blockResolver.ts │ │ ├── callResolver.ts │ │ ├── contractResolver.ts │ │ ├── index.ts │ │ ├── networkResolver.ts │ │ ├── pastEventsResolver.ts │ │ ├── transactionResolver.ts │ │ └── transactionsResolver.ts ├── services │ ├── __mocks__ │ │ ├── buildFilter.ts │ │ └── watchTransaction.ts │ ├── __tests__ │ │ ├── buildFilter.test.ts │ │ └── watchTransaction.test.ts │ ├── buildFilter.ts │ ├── index.ts │ ├── sendUncheckedTransaction.ts │ ├── watchNetworkAndAccount.ts │ └── watchTransaction.ts ├── subscribers │ ├── BlockSubscriptionManager.ts │ ├── EventSubscriptionManager.ts │ ├── __tests__ │ │ └── eventSubscriber.test.ts │ ├── blockSubscriber.ts │ ├── eventSubscriber.ts │ └── index.ts ├── typeDefs.ts ├── types │ ├── Block.ts │ ├── EventFilter.ts │ ├── EventTopics.ts │ ├── LogEvent.ts │ ├── ProviderSource.ts │ ├── Transaction.ts │ └── index.ts └── utils │ ├── __mocks__ │ └── castToJsonRpcProvider.ts │ ├── __tests__ │ └── castToJsonRpcProvider.test.ts │ ├── castToJsonRpcProvider.ts │ ├── encodeEventTopics.ts │ ├── gasCalculator.ts │ ├── index.ts │ └── normalizeAddress.ts ├── tsconfig.json ├── typedoc.json ├── yarn-error.log └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:8.14 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: yarn install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: yarn test -w 1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /coverage 4 | /docs 5 | .history 6 | 7 | # Below are the dist files. 8 | _virtual 9 | /abis 10 | /queries 11 | /resolvers 12 | /services 13 | /utils 14 | /ContractCache.js 15 | /index.js 16 | /Tightbeam.js 17 | /subscribers 18 | /types 19 | typeDefs.js 20 | /multicall 21 | 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | coverage 3 | docs 4 | node_modules 5 | scripts 6 | .gitignore 7 | jest.config.js 8 | rollup.config.js 9 | typedoc.json 10 | yarn-error.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tightbeam 2 | 3 | [![CircleCI](https://circleci.com/gh/pooltogether/tightbeam.svg?style=svg)](https://circleci.com/gh/pooltogether/tightbeam) [Test Coverage](https://coverage.tightbeam.pooltogether.com) 4 | 5 | Tightbeam is an extension for [Apollo Client](https://github.com/apollographql/apollo-client) that allows you to communicate with Ethereum smart contracts. 6 | 7 | Features: 8 | 9 | - Make calls to smart contracts. Calls are batched using [Multicall](https://github.com/makerdao/multicall) when supported by the network. 10 | - Get current network and account 11 | - Get blocks 12 | - Execute transactions 13 | 14 | Apollo Client is a powerful framework that allows web applications to communicate with GraphQL servers. By leveraging it's powerful caching and the Multicall smart contract it's possible to build extremely fast Ethereum dApps. 15 | 16 | ## Tutorial 17 | 18 | For a complete walkthrough of building a dapp and adding a subgraph to it checkout the [ETHDenver workshop](https://github.com/pooltogether/ethdenver-graphql-workshop). It will guide you through dapp creation and integrating a subgraph. 19 | 20 | # Installation 21 | 22 | Tightbeam is published under the scoped package `@pooltogether/tightbeam`. 23 | 24 | Within a project you can install it: 25 | 26 | ``` 27 | $ yarn add @pooltogether/tightbeam 28 | ``` 29 | 30 | ## Dependencies 31 | 32 | | Tested Dependency | Version | 33 | | ---------- | ------- | 34 | | [Ethers.js](https://github.com/ethers-io/ethers.js) | 4.x | 35 | | [Apollo Client](https://github.com/apollographql/apollo-client) | 2.6.x | 36 | | [Apollo Link State](https://github.com/apollographql/apollo-link-state) | 0.4.x | 37 | 38 | **Note:** The latest version of Apollo Client doesn't handle errors correctly when using client resolvers. See [issue 4575](https://github.com/apollographql/apollo-client/issues/4575). Errors will be swallowed. 39 | 40 | Instead, we recommended that you stick with Apollo Link State until the client has been updated. 41 | 42 | # Setup 43 | 44 | The simplest way to get started is to attach the Tightbeam resolvers to ApolloClient: 45 | 46 | ```javascript 47 | 48 | import { Tightbeam } from 'tightbeam' 49 | 50 | import { withClientState } from 'apollo-link-state' 51 | import { InMemoryCache } from 'apollo-cache-inmemory' 52 | import { createHttpLink } from 'apollo-link-http' 53 | 54 | const tb = new Tightbeam() 55 | 56 | const cache = new InMemoryCache() 57 | 58 | // Ensure that the expected defaults are present 59 | cache.writeData(tb.defaultCacheData()) 60 | 61 | // Now attach the Tightbeam resolvers 62 | const stateLink = withClientState({ 63 | cache, 64 | resolvers: tb.resolvers() 65 | }) 66 | 67 | const httpLink = createHttpLink({ 68 | uri: 'https://thegraph.com/yourgraphurl' 69 | }); 70 | 71 | client = new ApolloClient({ 72 | cache, 73 | link: ApolloLink.from([stateLink, httpLink]) 74 | }) 75 | 76 | ``` 77 | 78 | Note the use of `apollo-link-state`; it's required for multicall batching to work. 79 | 80 | The `defaultCacheData()` function takes one optional argument that is your desired default state. It will merge the two. 81 | 82 | The `resolvers()` function takes one optional argument of resolvers. It will merge the Tightbeam resolvers into the passed object. 83 | 84 | Now you can talk to Ethereum! 85 | 86 | # Usage 87 | 88 | Let's query for the current network and account: 89 | 90 | ```javascript 91 | 92 | const result = await client.query({ 93 | query: gql` 94 | query addressAndNetwork { 95 | address @client 96 | network @client 97 | } 98 | ` 99 | }) 100 | 101 | console.log(result) 102 | /* 103 | 104 | { 105 | address: "0x1234...", 106 | network: { 107 | name: 'homestead', 108 | chainId: 1 109 | } 110 | } 111 | 112 | */ 113 | ``` 114 | 115 | Notice the `@client` directive; this tells Apollo Client that we are querying a client resolver. 116 | 117 | # Querying a Contract 118 | 119 | To query a contract, you must first add a contract to the abi mapping: 120 | 121 | ```javascript 122 | const erc20Abi = // ... get the abi from some where 123 | 124 | // addContract(name, networkId, address, abi) 125 | tb.abiMapping.addContract('Dai', 1, '0x6b175474e89094c44da98b954eedeac495271d0f', erc20Abi) 126 | ``` 127 | 128 | Now you can query functions: 129 | 130 | ```javascript 131 | const result = await client.query({ 132 | query: gql` 133 | query daiQuery { 134 | name: call(name: Dai, fn: name) @client 135 | totalSupply: call(name: Dai, fn: totalSupply) @client 136 | } 137 | ` 138 | }) 139 | ``` 140 | 141 | We can ask for our balance as well: 142 | 143 | ```javascript 144 | const result = await client.query({ 145 | query: gql` 146 | query myBalanceQuery($address: String!) { 147 | balance: call(name; Dai, fn: balanceOf, params[$address]) @client 148 | } 149 | `, 150 | variables: { 151 | address: '0xc73e0383f3aff3215e6f04b0331d58cecf0ab849' 152 | } 153 | }) 154 | ``` 155 | 156 | The query defines an `address` variable that can configure the call. 157 | 158 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/_n/3grfpp_53vnfrzp3gdr69mg40000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | "roots": [ 30 | "src" 31 | ], 32 | 33 | testMatch: [ 34 | "**/__tests__/**/?(*.)+(spec|test).+(ts|tsx|js)" 35 | ], 36 | 37 | "transform": { 38 | "^.+\\.(ts|tsx)$": "ts-jest" 39 | }, 40 | 41 | // An array of regexp pattern strings used to skip coverage collection 42 | // coveragePathIgnorePatterns: [ 43 | // "/node_modules/" 44 | // ], 45 | 46 | // A list of reporter names that Jest uses when writing coverage reports 47 | // coverageReporters: [ 48 | // "json", 49 | // "text", 50 | // "lcov", 51 | // "clover" 52 | // ], 53 | 54 | // An object that configures minimum threshold enforcement for coverage results 55 | // coverageThreshold: null, 56 | 57 | // A path to a custom dependency extractor 58 | // dependencyExtractor: null, 59 | 60 | // Make calling deprecated APIs throw helpful error messages 61 | // errorOnDeprecated: false, 62 | 63 | // Force coverage collection from ignored files using an array of glob patterns 64 | // forceCoverageMatch: [], 65 | 66 | // A path to a module which exports an async function that is triggered once before all test suites 67 | // globalSetup: null, 68 | 69 | // A path to a module which exports an async function that is triggered once after all test suites 70 | // globalTeardown: null, 71 | 72 | // A set of global variables that need to be available in all test environments 73 | // globals: {}, 74 | 75 | // An array of directory names to be searched recursively up from the requiring module's location 76 | // moduleDirectories: [ 77 | // "node_modules" 78 | // ], 79 | 80 | // An array of file extensions your modules use 81 | // moduleFileExtensions: [ 82 | // "js", 83 | // "json", 84 | // "jsx", 85 | // "ts", 86 | // "tsx", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: null, 104 | 105 | // Run tests from one or more projects 106 | // projects: null, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state between every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: null, 119 | 120 | // Automatically restore mock state between every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: null, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 141 | // snapshotSerializers: [], 142 | 143 | // The test environment that will be used for testing 144 | // testEnvironment: "jest-environment-jsdom", 145 | 146 | // Options that will be passed to the testEnvironment 147 | // testEnvironmentOptions: {}, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | // testMatch: [ 154 | // "**/__tests__/**/*.[jt]s?(x)", 155 | // "**/?(*.)+(spec|test).[tj]s?(x)" 156 | // ], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | // testPathIgnorePatterns: [ 160 | // "/node_modules/" 161 | // ], 162 | 163 | // The regexp pattern or array of patterns that Jest uses to detect test files 164 | // testRegex: [], 165 | 166 | // This option allows the use of a custom results processor 167 | // testResultsProcessor: null, 168 | 169 | // This option allows use of a custom test runner 170 | // testRunner: "jasmine2", 171 | 172 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 173 | // testURL: "http://localhost", 174 | 175 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 176 | // timers: "real", 177 | 178 | // A map from regular expressions to paths to transformers 179 | // transform: null, 180 | 181 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 182 | // transformIgnorePatterns: [ 183 | // "/node_modules/" 184 | // ], 185 | 186 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 187 | // unmockedModulePathPatterns: undefined, 188 | 189 | // Indicates whether each individual test should be reported during the run 190 | // verbose: null, 191 | 192 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 193 | // watchPathIgnorePatterns: [], 194 | 195 | // Whether to use watchman for file crawling 196 | // watchman: true, 197 | }; 198 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pooltogether/tightbeam", 3 | "version": "1.0.11", 4 | "description": "Ethers.js bindings for Apollo Client", 5 | "main": "index.js", 6 | "browser": "index.js", 7 | "types": "src/index.ts", 8 | "repository": "https://github.com/pooltogether/tightbeam", 9 | "author": "Brendan Asselstine ", 10 | "license": "MIT", 11 | "scripts": { 12 | "test": "jest -w 1", 13 | "dist": "rollup -c rollup.config.js", 14 | "dist-clean": "rm -rf _virtual abis queries resolvers services subscribers types utils ContractCache.js index.js Tightbeam.js multicall", 15 | "watch": "rollup -w -c rollup.config.js", 16 | "coverage": "jest --coverage --coverageReporters html", 17 | "test-watch": "jest --watchAll", 18 | "docs": "typedoc src", 19 | "prepack": "yarn dist", 20 | "postpublish": "PACKAGE_VERSION=$(cat package.json | grep \\\"version\\\" | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]') && git tag v$PACKAGE_VERSION && git push --tags" 21 | }, 22 | "directories": { 23 | "lib": "dist", 24 | "src": "src" 25 | }, 26 | "dependencies": { 27 | "debug": "^4.1.1", 28 | "lodash": "^4.17.15", 29 | "lodash.merge": "^4.6.2", 30 | "zen-observable-ts": "^0.8.20" 31 | }, 32 | "peerDependencies": { 33 | "apollo-client": "^2.6.8", 34 | "apollo-link-state": "^0.4.2", 35 | "ethers": "^4.0.39", 36 | "graphql-anywhere": "^4.2.6", 37 | "graphql-tag": "^2.10.1" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^24.0.23", 41 | "apollo-cache-inmemory": "^1.6.3", 42 | "apollo-client": "^2.6.8", 43 | "apollo-link-http": "^1.5.16", 44 | "apollo-link-state": "^0.4.2", 45 | "ethers": "^4.0.39", 46 | "graphql": "^14.5.8", 47 | "graphql-anywhere": "^4.2.6", 48 | "graphql-tag": "^2.10.1", 49 | "jest": "^24.9.0", 50 | "jest-leak-detector": "^25.1.0", 51 | "node-fetch": "^2.6.0", 52 | "promise.allsettled": "^1.0.2", 53 | "rollup": "^1.27.4", 54 | "rollup-plugin-typescript": "^1.0.1", 55 | "ts-jest": "^24.2.0", 56 | "tslib": "^1.10.0", 57 | "typedoc": "^0.15.3", 58 | "typescript": "^3.7.2", 59 | "weak": "^1.0.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript'; 2 | 3 | export default { 4 | input: 'src/index.ts', 5 | output: { 6 | dir: '.', 7 | format: 'cjs', 8 | }, 9 | preserveModules: true, 10 | external: [ 11 | 'apollo-client', 12 | 'apollo-cache-inmemory', 13 | 'apollo-link', 14 | 'graphql-tag', 15 | 'graphql', 16 | 'graphql-anywhere', 17 | 'ethers', 18 | 'date-fns', 19 | 'lodash', 20 | 'zen-observable-ts' 21 | ], 22 | plugins: [ 23 | typescript() 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | yarn dist 3 | yarn publish --access public 4 | yarn dist-clean -------------------------------------------------------------------------------- /src/ContractCache.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | import { AbiMapping } from './abis/AbiMapping' 4 | import { ProviderSource } from './types/ProviderSource' 5 | import { normalizeAddress } from './utils/normalizeAddress' 6 | import { AbiDefinition } from './abis' 7 | 8 | const debug = require('debug')('tightbeam:ContractCache') 9 | 10 | interface ResolveContractOptions { 11 | abi?: string 12 | name?: string 13 | contract?: string 14 | address?: string 15 | } 16 | 17 | function error(message): void { 18 | debug(message) 19 | throw new Error(message) 20 | } 21 | 22 | /** 23 | * Look up contracts or ABIs by name or address. Values are cached. Also offers a contract resolver to easily retrieve an [[ethers.Contract]] object. 24 | */ 25 | export class ContractCache { 26 | contractCache: Map> 27 | ifaceCache: Map> 28 | 29 | constructor ( 30 | public readonly abiMapping: AbiMapping, 31 | public readonly providerSource: ProviderSource 32 | ) { 33 | if (!abiMapping) { 34 | error('constructor: abiMapping must be defined') 35 | } 36 | if (!providerSource) { 37 | error('constructor: provider source must be defined') 38 | } 39 | this.contractCache = new Map>() 40 | this.ifaceCache = new Map>() 41 | } 42 | 43 | /** 44 | * Lookup a contract by name. 45 | * 46 | * @param contractName The contract name used to register the contract in the [[AbiMapping]] 47 | */ 48 | async getContractByName (contractName: string): Promise { 49 | if (!contractName) error('Contract name must be defined') 50 | const chainId = await this.getChainId() 51 | let address = this.abiMapping.getContractAddress(contractName, chainId) 52 | if (!address) { 53 | error(`Cannot find address for ${contractName} and chain id ${chainId}`) 54 | } 55 | return await this.getContractByAddress(address) 56 | } 57 | 58 | async getChainId(): Promise { 59 | const provider = await this.providerSource() 60 | const network = await provider.getNetwork() 61 | const { chainId } = network 62 | return chainId 63 | } 64 | 65 | /** 66 | * Lookup a contract by address 67 | * 68 | * @param address The address that the contract was added under in the [[AbiMapping]] 69 | */ 70 | async getContractByAddress (address: string, abi?: string): Promise { 71 | if (!address) error('Address must be defined') 72 | 73 | const chainId = await this.getChainId() 74 | 75 | if (!this.contractCache[chainId]) { 76 | this.contractCache[chainId] = {} 77 | } 78 | 79 | address = normalizeAddress(address) 80 | 81 | let contract = this.contractCache[chainId][address] 82 | if (!contract) { 83 | let abiDef: AbiDefinition 84 | if (abi) { 85 | abiDef = await this.abiMapping.getAbiDefinition(abi) 86 | if (!abiDef) { 87 | error(`Could not find abi with name '${abi}'`) 88 | } 89 | } else { 90 | abiDef = this.abiMapping.getContractAbiDefinitionByAddress(address, chainId) 91 | if (!abiDef) { 92 | error(`Could not find abi for address '${address}' and chain id '${chainId}'`) 93 | } 94 | } 95 | const provider = await this.providerSource() 96 | contract = new ethers.Contract(address, abiDef.abi, provider) 97 | this.contractCache[chainId][address] = contract 98 | } 99 | return contract 100 | } 101 | 102 | /** 103 | * Lookup an [[ethers.utils.Interface]] by abi name 104 | * 105 | * @param abiName The name of the ABI added in the [[AbiMapping]] 106 | */ 107 | async getAbiInterfaceByName (abiName): Promise { 108 | let iface = this.ifaceCache[abiName] 109 | if (!iface) { 110 | const abiDef = this.abiMapping.getAbiDefinition(abiName) 111 | if (!abiDef) { 112 | error(`Could not find abi with name ${abiName}`) 113 | } 114 | iface = new ethers.utils.Interface(abiDef.abi) 115 | this.ifaceCache[abiName] = iface 116 | } 117 | return iface 118 | } 119 | 120 | async resolveContract(resolveContractOptions: ResolveContractOptions): Promise { 121 | let { abi, address, name, contract } = resolveContractOptions 122 | 123 | let contractName = name || contract 124 | 125 | address = normalizeAddress(address) 126 | 127 | let result 128 | if (address) { 129 | result = await this.getContractByAddress(address, abi) 130 | } else if (contractName) { 131 | result = await this.getContractByName(contractName) 132 | } else if (abi) { 133 | error(`abi '${abi}' selected but no address passed`) 134 | } else { 135 | error(`abi, address or contract must be defined`) 136 | } 137 | return result 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Tightbeam.ts: -------------------------------------------------------------------------------- 1 | import * as abis from './abis' 2 | import * as queries from './queries' 3 | import * as resolvers from './resolvers' 4 | import * as services from './services' 5 | import * as subscribers from './subscribers' 6 | import * as types from './types' 7 | import * as utils from './utils' 8 | 9 | export * from './ContractCache' 10 | 11 | export { 12 | abis, 13 | queries, 14 | resolvers, 15 | services, 16 | subscribers, 17 | types, 18 | utils 19 | } 20 | 21 | export { AbiMapping } from './abis' 22 | 23 | import { ContractCache } from './ContractCache' 24 | import { AbiMapping } from './abis/AbiMapping' 25 | import { ProviderSource } from './types/ProviderSource' 26 | import { ethers } from 'ethers' 27 | import { bindResolvers } from './resolvers/bindResolvers' 28 | import { EventFilter } from './types' 29 | import { eventSubscriber, EventSubscriptionManager, BlockSubscriptionManager } from './subscribers' 30 | import Observable from 'zen-observable-ts' 31 | import { Log } from 'ethers/providers' 32 | import { MulticallLink } from './multicall/MulticallLink' 33 | 34 | const merge = require('lodash.merge') 35 | 36 | export interface TightbeamOptions { 37 | abiMapping?: AbiMapping 38 | providerSource?: ProviderSource, 39 | txProviderSource?: ProviderSource, 40 | defaultFromBlock?: number 41 | } 42 | 43 | export class Tightbeam { 44 | public contractCache: ContractCache 45 | public txContractCache: ContractCache 46 | public abiMapping: AbiMapping 47 | public providerSource: ProviderSource 48 | public txProviderSource: ProviderSource 49 | public defaultFromBlock: number 50 | 51 | private blockSubscriptionManager: BlockSubscriptionManager 52 | private eventSubscriptionManager: EventSubscriptionManager 53 | 54 | private logsSubscriber: Observable> 55 | 56 | private subscribersReady: Promise 57 | 58 | constructor ( 59 | options?: TightbeamOptions 60 | ) { 61 | const { 62 | providerSource, 63 | txProviderSource, 64 | abiMapping, 65 | defaultFromBlock 66 | } = options || {} 67 | this.providerSource = providerSource || (async () => ethers.getDefaultProvider()) 68 | this.txProviderSource = txProviderSource || this.providerSource 69 | this.abiMapping = abiMapping || new AbiMapping() 70 | this.defaultFromBlock = defaultFromBlock || 0 71 | this.contractCache = new ContractCache(this.abiMapping, this.providerSource) 72 | this.txContractCache = new ContractCache(this.abiMapping, this.txProviderSource) 73 | this.blockSubscriptionManager = new BlockSubscriptionManager(this.providerSource) 74 | this.eventSubscriptionManager = new EventSubscriptionManager(this.providerSource, this.blockSubscriptionManager) 75 | } 76 | 77 | resolvers (clientResolvers = {}) { 78 | return merge(clientResolvers, this.bindResolvers()) 79 | } 80 | 81 | multicallLink () { 82 | return new MulticallLink(this.providerSource) 83 | } 84 | 85 | async subscribeEvent (eventFilter: EventFilter) { 86 | await this.startSubscribers() 87 | return await eventSubscriber(this.contractCache, this.logsSubscriber, eventFilter) 88 | } 89 | 90 | async startSubscribers() { 91 | if (!this.subscribersReady) { 92 | this.subscribersReady = new Promise((resolve, reject) => { 93 | const setup = async () => { 94 | this.blockSubscriptionManager.start() 95 | this.eventSubscriptionManager.start() 96 | this.logsSubscriber = await this.eventSubscriptionManager.subscribe() 97 | } 98 | setup().then(resolve).catch(reject) 99 | }) 100 | } 101 | return await this.subscribersReady 102 | } 103 | 104 | bindResolvers () { 105 | return bindResolvers(this) 106 | } 107 | 108 | defaultCacheData(otherState = {}) { 109 | return merge(otherState, 110 | { 111 | data: { 112 | _transactions: [] 113 | } 114 | } 115 | ) 116 | } 117 | } -------------------------------------------------------------------------------- /src/__mocks__/abi.ts: -------------------------------------------------------------------------------- 1 | export const abi = [ 2 | { 3 | "anonymous": false, 4 | "inputs": [ 5 | { 6 | "indexed": true, 7 | "name": "from", 8 | "type": "address" 9 | }, 10 | { 11 | "indexed": true, 12 | "name": "to", 13 | "type": "address" 14 | }, 15 | { 16 | "indexed": false, 17 | "name": "value", 18 | "type": "uint256" 19 | } 20 | ], 21 | "name": "Transfer", 22 | "type": "event" 23 | }, 24 | { 25 | "anonymous": false, 26 | "inputs": [ 27 | { 28 | "indexed": true, 29 | "name": "owner", 30 | "type": "address" 31 | }, 32 | { 33 | "indexed": true, 34 | "name": "spender", 35 | "type": "address" 36 | }, 37 | { 38 | "indexed": false, 39 | "name": "value", 40 | "type": "uint256" 41 | } 42 | ], 43 | "name": "Approval", 44 | "type": "event" 45 | }, 46 | { 47 | "constant": true, 48 | "inputs": [], 49 | "name": "totalSupply", 50 | "outputs": [ 51 | { 52 | "name": "", 53 | "type": "uint256" 54 | } 55 | ], 56 | "payable": false, 57 | "stateMutability": "view", 58 | "type": "function" 59 | }, 60 | { 61 | "constant": true, 62 | "inputs": [ 63 | { 64 | "name": "who", 65 | "type": "address" 66 | } 67 | ], 68 | "name": "balanceOf", 69 | "outputs": [ 70 | { 71 | "name": "", 72 | "type": "uint256" 73 | } 74 | ], 75 | "payable": false, 76 | "stateMutability": "view", 77 | "type": "function" 78 | }, 79 | { 80 | "constant": true, 81 | "inputs": [ 82 | { 83 | "name": "owner", 84 | "type": "address" 85 | }, 86 | { 87 | "name": "spender", 88 | "type": "address" 89 | } 90 | ], 91 | "name": "allowance", 92 | "outputs": [ 93 | { 94 | "name": "", 95 | "type": "uint256" 96 | } 97 | ], 98 | "payable": false, 99 | "stateMutability": "view", 100 | "type": "function" 101 | }, 102 | { 103 | "constant": false, 104 | "inputs": [ 105 | { 106 | "name": "to", 107 | "type": "address" 108 | }, 109 | { 110 | "name": "value", 111 | "type": "uint256" 112 | } 113 | ], 114 | "name": "transfer", 115 | "outputs": [ 116 | { 117 | "name": "", 118 | "type": "bool" 119 | } 120 | ], 121 | "payable": false, 122 | "stateMutability": "nonpayable", 123 | "type": "function" 124 | }, 125 | { 126 | "constant": false, 127 | "inputs": [ 128 | { 129 | "name": "spender", 130 | "type": "address" 131 | }, 132 | { 133 | "name": "value", 134 | "type": "uint256" 135 | } 136 | ], 137 | "name": "approve", 138 | "outputs": [ 139 | { 140 | "name": "", 141 | "type": "bool" 142 | } 143 | ], 144 | "payable": false, 145 | "stateMutability": "nonpayable", 146 | "type": "function" 147 | }, 148 | { 149 | "constant": false, 150 | "inputs": [ 151 | { 152 | "name": "from", 153 | "type": "address" 154 | }, 155 | { 156 | "name": "to", 157 | "type": "address" 158 | }, 159 | { 160 | "name": "value", 161 | "type": "uint256" 162 | } 163 | ], 164 | "name": "transferFrom", 165 | "outputs": [ 166 | { 167 | "name": "", 168 | "type": "bool" 169 | } 170 | ], 171 | "payable": false, 172 | "stateMutability": "nonpayable", 173 | "type": "function" 174 | } 175 | ] 176 | -------------------------------------------------------------------------------- /src/__mocks__/abi2.ts: -------------------------------------------------------------------------------- 1 | export const abi2 = [ 2 | { 3 | "constant": false, 4 | "inputs": [ 5 | { 6 | "name": "from", 7 | "type": "address" 8 | }, 9 | { 10 | "name": "to", 11 | "type": "address" 12 | }, 13 | { 14 | "name": "value", 15 | "type": "uint256" 16 | } 17 | ], 18 | "name": "transferFrom", 19 | "outputs": [ 20 | { 21 | "name": "", 22 | "type": "bool" 23 | } 24 | ], 25 | "payable": false, 26 | "stateMutability": "nonpayable", 27 | "type": "function" 28 | } 29 | ] 30 | -------------------------------------------------------------------------------- /src/__mocks__/ethers.ts: -------------------------------------------------------------------------------- 1 | const ethersOriginal = jest.requireActual('ethers') 2 | 3 | export const ethers = { 4 | utils: { 5 | Interface: jest.fn(), 6 | bigNumberify: ethersOriginal.utils.bigNumberify, 7 | defaultAbiCoder: ethersOriginal.utils.defaultAbiCoder, 8 | getAddress: ethersOriginal.utils.getAddress, 9 | hexlify: ethersOriginal.utils.hexlify, 10 | getDefaultProvider: () => jest.fn() 11 | }, 12 | Contract: jest.fn() 13 | } -------------------------------------------------------------------------------- /src/__tests__/ContractCache.test.ts: -------------------------------------------------------------------------------- 1 | import { ContractCache } from '../ContractCache' 2 | import { AbiMapping } from '../abis/AbiMapping' 3 | import { ethers } from 'ethers' 4 | 5 | describe('ContractCache', () => { 6 | 7 | let abiMapping 8 | let providerCb 9 | let cache 10 | 11 | let abi = [ 12 | { 13 | "anonymous": false, 14 | "inputs": [ 15 | { 16 | "indexed": true, 17 | "name": "from", 18 | "type": "address" 19 | }, 20 | { 21 | "indexed": true, 22 | "name": "to", 23 | "type": "address" 24 | }, 25 | { 26 | "indexed": false, 27 | "name": "value", 28 | "type": "uint256" 29 | } 30 | ], 31 | "name": "Transfer", 32 | "type": "event" 33 | } 34 | ] 35 | 36 | const abi2 = [ 37 | { 38 | "constant": false, 39 | "inputs": [ 40 | { 41 | "name": "from", 42 | "type": "address" 43 | }, 44 | { 45 | "name": "to", 46 | "type": "address" 47 | }, 48 | { 49 | "name": "value", 50 | "type": "uint256" 51 | } 52 | ], 53 | "name": "transferFrom", 54 | "outputs": [ 55 | { 56 | "name": "", 57 | "type": "bool" 58 | } 59 | ], 60 | "payable": false, 61 | "stateMutability": "nonpayable", 62 | "type": "function" 63 | } 64 | ] 65 | 66 | 67 | beforeEach(async () => { 68 | abiMapping = new AbiMapping() 69 | abiMapping.addContract('Foo', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 70 | 71 | let provider = { 72 | getNetwork: jest.fn(() => Promise.resolve({ chainId: 1234 })) 73 | } 74 | 75 | providerCb = jest.fn(() => Promise.resolve(provider)) 76 | 77 | // @ts-ignore 78 | cache = new ContractCache(abiMapping, providerCb) 79 | }) 80 | 81 | describe('constructor', () => { 82 | it('should throw when no abiMapping passed', () => { 83 | // @ts-ignore 84 | expect(() => new ContractCache(null, providerCb)).toThrow(/abiMapping must be defined/) 85 | }) 86 | 87 | it('should throw when no provider passed', () => { 88 | // @ts-ignore 89 | expect(() => new ContractCache(abiMapping)).toThrow(/provider source must be defined/) 90 | }) 91 | }) 92 | 93 | describe('getContractByName()', () => { 94 | it('should retrieve a contract', async () => { 95 | const result = await cache.getContractByName('Foo') 96 | expect(result).toBeInstanceOf(ethers.Contract) 97 | 98 | // and ensure it reuses the contract 99 | let result2 = await cache.getContractByName('Foo') 100 | expect(result).toBe(result2) 101 | }) 102 | 103 | it('should fail if contract name is not passed', async () => { 104 | await expect(cache.getContractByName()).rejects.toEqual(new Error('Contract name must be defined')) 105 | }) 106 | 107 | it('should fail if contract name does not exist', async () => { 108 | await expect(cache.getContractByName('Blarg')).rejects.toEqual(new Error('Cannot find address for Blarg and chain id 1234')) 109 | }) 110 | }) 111 | 112 | describe('getContractByAddress()', () => { 113 | it('should retrieve a contract', async () => { 114 | const result = await cache.getContractByAddress('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e') 115 | expect(result).toBeInstanceOf(ethers.Contract) 116 | }) 117 | 118 | it('should fail if no address is passed', async () => { 119 | await expect(cache.getContractByAddress()).rejects.toEqual(new Error('Address must be defined')) 120 | }) 121 | 122 | it('should fail if the contract does not exist', async () => { 123 | await expect(cache.getContractByAddress('0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6')).rejects.toEqual(new Error('Could not find abi for address \'0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6\' and chain id \'1234\'')) 124 | }) 125 | }) 126 | 127 | describe('getAbiInterfaceByName()', () => { 128 | beforeEach(async () => { 129 | abiMapping.addAbi('Abby', abi) 130 | }) 131 | 132 | it('should return the correct abi interface', async () => { 133 | const result = await cache.getAbiInterfaceByName('Abby') 134 | expect(result).toBeInstanceOf(ethers.utils.Interface) 135 | 136 | // should reuse 137 | const result2 = await cache.getAbiInterfaceByName('Abby') 138 | expect(result).toBe(result2) 139 | }) 140 | 141 | it('should throw when abi missing', async () => { 142 | await expect(cache.getAbiInterfaceByName('Abber')).rejects.toEqual(new Error('Could not find abi with name Abber')) 143 | }) 144 | }) 145 | 146 | describe('resolveContract()', () => { 147 | beforeEach(() => { 148 | abiMapping.addAbi('Hello', abi2) 149 | abiMapping.addContract('Hello', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 150 | }) 151 | 152 | it('should fail when no params are passed', async () => { 153 | await expect(cache.resolveContract({})).rejects.toEqual(new Error(`abi, address or contract must be defined`)) 154 | }) 155 | 156 | describe('when passing an abi', () => { 157 | it('should use the abi first', async () => { 158 | const result = await cache.resolveContract({ abi: 'Hello', name: 'Hello', address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6' }) 159 | 160 | expect(result).toBeInstanceOf(ethers.Contract) 161 | }) 162 | 163 | it('should require an address', async () => { 164 | await expect(cache.resolveContract({ abi: 'Hello' })).rejects.toEqual(new Error('abi \'Hello\' selected but no address passed')) 165 | }) 166 | }) 167 | 168 | describe('when passing a contract name', () => { 169 | it('should use the abi first', async () => { 170 | const result = await cache.resolveContract({ name: 'Hello' }) 171 | 172 | // NOTE: this is a problem. Here we really need to test that we're setting the right values 173 | expect(result).toBeInstanceOf(ethers.Contract) 174 | }) 175 | }) 176 | 177 | describe('when passing an address', () => { 178 | it('should use the abi first', async () => { 179 | const result = await cache.resolveContract({ address: '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e' }) 180 | 181 | expect(result).toBeInstanceOf(ethers.Contract) 182 | }) 183 | }) 184 | }) 185 | }) -------------------------------------------------------------------------------- /src/__tests__/Tightbeam.test.ts: -------------------------------------------------------------------------------- 1 | import { AbiMapping } from '../abis/AbiMapping' 2 | import { ContractCache } from '../ContractCache' 3 | import { ethers } from 'ethers' 4 | 5 | const { Tightbeam } = require('../Tightbeam') 6 | 7 | jest.mock('../subscribers/eventSubscriber') 8 | 9 | const { eventSubscriber } = require('../subscribers/eventSubscriber') 10 | 11 | describe('Tightbeam', () => { 12 | describe('contructor()', () => { 13 | it('should not need args', () => { 14 | const tb = new Tightbeam() 15 | 16 | expect(tb.defaultFromBlock).toEqual(0) 17 | expect(tb.abiMapping).toBeInstanceOf(AbiMapping) 18 | expect(tb.contractCache).toBeInstanceOf(ContractCache) 19 | }) 20 | 21 | it('should take args', () => { 22 | const abiMapping = new AbiMapping() 23 | const providerSource = async () => ethers.getDefaultProvider() 24 | 25 | const tb = new Tightbeam({ 26 | defaultFromBlock: 10, 27 | abiMapping, 28 | providerSource 29 | }) 30 | 31 | expect(tb.providerSource).toBe(providerSource) 32 | expect(tb.abiMapping).toBe(abiMapping) 33 | }) 34 | }) 35 | 36 | describe('resolvers()', () => { 37 | 38 | it("should return resolvers", () => { 39 | const tb = new Tightbeam() 40 | 41 | const resolvers = tb.resolvers() 42 | expect(resolvers).toHaveProperty('Query') 43 | expect(resolvers).toHaveProperty('Mutation') 44 | }) 45 | 46 | it('should merge passed resolvers', () => { 47 | const tb = new Tightbeam() 48 | 49 | const resolvers = tb.resolvers({ 50 | Query: { 51 | foo: () => 'hello' 52 | } 53 | }) 54 | 55 | expect(resolvers).toHaveProperty('Query.foo') 56 | expect(resolvers).toHaveProperty('Query.call') 57 | expect(resolvers).toHaveProperty('Mutation.sendTransaction') 58 | }) 59 | }) 60 | 61 | describe('defaultCacheData()', () => { 62 | it('should return the default data', () => { 63 | const tb = new Tightbeam() 64 | 65 | expect(tb.defaultCacheData()).toEqual({ 66 | data: { 67 | _transactions: [] 68 | } 69 | }) 70 | }) 71 | 72 | it('should merge other data', () => { 73 | const tb = new Tightbeam() 74 | 75 | expect(tb.defaultCacheData({ data: { foo: 'bar' }})).toEqual({ 76 | data: { 77 | foo: 'bar', 78 | _transactions: [] 79 | } 80 | }) 81 | }) 82 | }) 83 | 84 | describe('subscribeEvent()', () => { 85 | it('should create an event subscriber', async () => { 86 | const tb = new Tightbeam() 87 | 88 | await tb.subscribeEvent('test') 89 | 90 | expect(eventSubscriber).toHaveBeenCalledWith(tb.contractCache, expect.anything(), 'test') 91 | }) 92 | }) 93 | }) -------------------------------------------------------------------------------- /src/abis/AbiDefinition.ts: -------------------------------------------------------------------------------- 1 | export class AbiDefinition { 2 | nameLookup: object 3 | abi: Array 4 | 5 | constructor (abi: Array) { 6 | if (!abi) { throw new Error('abi is undefined') } 7 | this.abi = abi 8 | this.nameLookup = {} 9 | this.abi.forEach(def => { 10 | this.nameLookup[def.name] = def 11 | }) 12 | } 13 | 14 | findByName (name: string): object { 15 | return this.nameLookup[name] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/abis/AbiMapping.ts: -------------------------------------------------------------------------------- 1 | import { AbiDefinition } from './AbiDefinition' 2 | 3 | import { normalizeAddress } from '../utils/normalizeAddress' 4 | 5 | const debug = require('debug')('tightbeam:AbiMapping') 6 | 7 | export class AbiMapping { 8 | abiMapping: Map 9 | nameToAddressMapping: Map> 10 | addressToAbiMapping: Map> 11 | 12 | constructor () { 13 | this.abiMapping = new Map() 14 | this.nameToAddressMapping = new Map>() 15 | this.addressToAbiMapping = new Map>() 16 | } 17 | 18 | addAbi(name: string, abi: Array): void { 19 | if (!name) throw `ABI cannot be mapped to a null name` 20 | if (!abi) throw `ABI cannot be null` 21 | 22 | debug(`addAbi(${name}, ${JSON.stringify(abi)}`) 23 | 24 | this.abiMapping[name] = new AbiDefinition(abi) 25 | } 26 | 27 | addContract(name: string, networkId: Number, address: string, abi: Array) { 28 | if (!name) throw 'name not defined' 29 | if (!networkId) throw 'networkId not defined' 30 | if (!address) throw 'address not defined' 31 | if (!abi) throw 'abi not defined' 32 | 33 | address = normalizeAddress(address) 34 | 35 | if (!this.nameToAddressMapping[name]) { 36 | this.nameToAddressMapping[name] = {} 37 | } 38 | if (!this.addressToAbiMapping[address]) { 39 | this.addressToAbiMapping[address] = {} 40 | } 41 | 42 | debug(`addContract(${name}, ${networkId}, ${address}, ${JSON.stringify(abi)}`) 43 | 44 | this.nameToAddressMapping[name][networkId] = address 45 | this.addressToAbiMapping[address][networkId] = new AbiDefinition(abi) 46 | } 47 | 48 | addTruffleArtifact (truffleJsonArtifact: any) { 49 | Object.keys(truffleJsonArtifact.networks).forEach(networkId => { 50 | debug(`addTruffleArtifact addContract(${truffleJsonArtifact.contractName}, ${networkId}, ${truffleJsonArtifact.networks[networkId].address}, ${JSON.stringify(truffleJsonArtifact.abi)}`) 51 | this.addContract(truffleJsonArtifact.contractName, parseInt(networkId), truffleJsonArtifact.networks[networkId].address, truffleJsonArtifact.abi) 52 | }) 53 | } 54 | 55 | getAbiDefinition(name: string): AbiDefinition { 56 | return this.abiMapping[name] 57 | } 58 | 59 | getContractAbiDefinitionByName(name: string, networkId: Number): AbiDefinition { 60 | const mapping = this.nameToAddressMapping[name] 61 | let result 62 | if (mapping) { 63 | const address = this.nameToAddressMapping[name][networkId] 64 | result = this.getContractAbiDefinitionByAddress(address, networkId) 65 | } 66 | return result 67 | } 68 | 69 | getContractAbiDefinitionByAddress(address: string, networkId: Number): AbiDefinition { 70 | address = normalizeAddress(address) 71 | 72 | const mapping = this.addressToAbiMapping[address] 73 | let result 74 | if (mapping) { 75 | result = mapping[networkId] 76 | } 77 | return result 78 | } 79 | 80 | getContractAddress(name: string, networkId: Number) { 81 | if (!this.nameToAddressMapping[name]) { 82 | return undefined 83 | } 84 | return this.nameToAddressMapping[name][networkId] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/abis/__mocks__/AbiMapping.ts: -------------------------------------------------------------------------------- 1 | // Import this named export into your test file: 2 | export const mockGetAbi = jest.fn(); 3 | 4 | export default jest.fn().mockImplementation(() => { 5 | return { 6 | getAbi: mockGetAbi 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /src/abis/__tests__/AbiDefinition.test.ts: -------------------------------------------------------------------------------- 1 | import { AbiDefinition } from '../AbiDefinition' 2 | import { abi } from '../../__mocks__/abi' 3 | 4 | describe('AbiDefinition', () => { 5 | describe('constructor()', () => { 6 | it('Should require an abi', () => { 7 | expect(() => new AbiDefinition(null)).toThrow() 8 | }) 9 | 10 | it('should accept an abi', () => { 11 | const abiDef = new AbiDefinition(abi) 12 | }) 13 | }) 14 | 15 | describe('findByName', () => { 16 | it('should correctly lookup a def', () => { 17 | const abiDef = new AbiDefinition(abi) 18 | expect(abiDef.findByName('Transfer')).toEqual( 19 | { 20 | "anonymous": false, 21 | "inputs": [ 22 | { 23 | "indexed": true, 24 | "name": "from", 25 | "type": "address" 26 | }, 27 | { 28 | "indexed": true, 29 | "name": "to", 30 | "type": "address" 31 | }, 32 | { 33 | "indexed": false, 34 | "name": "value", 35 | "type": "uint256" 36 | } 37 | ], 38 | "name": "Transfer", 39 | "type": "event" 40 | } 41 | ) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/abis/__tests__/AbiMapping.test.ts: -------------------------------------------------------------------------------- 1 | import { AbiMapping } from '../AbiMapping' 2 | import { AbiDefinition } from '../AbiDefinition' 3 | import { abi } from '../../__mocks__/abi' 4 | 5 | describe('AbiMapping', () => { 6 | const abiDef = new AbiDefinition(abi) 7 | 8 | let mapping 9 | 10 | beforeEach(() => { 11 | mapping = new AbiMapping() 12 | }) 13 | 14 | describe('constructor()', () => { 15 | it('should not need arguments', () => { 16 | new AbiMapping() 17 | }) 18 | }) 19 | 20 | describe('addAbi()', () => { 21 | it('should require non-null name', () => { 22 | expect(() => { 23 | mapping.addAbi(null, abi) 24 | }).toThrow() 25 | }) 26 | 27 | it('should require non-null abi', () => { 28 | expect(() => { 29 | mapping.addAbi('hello', null) 30 | }).toThrow() 31 | }) 32 | 33 | it('should add an abi', () => { 34 | mapping.addAbi('hello', abi) 35 | 36 | expect(mapping.getAbiDefinition('hello')).toEqual(abiDef) 37 | }) 38 | }) 39 | 40 | describe('addContract()', () => { 41 | it('should require name', () => { 42 | expect(() => mapping.addContract(null, 1234, '0x0CcCC7507aEDf9FEaF8C8D731421746e16b4d39D', abi)).toThrow(/name not defined/) 43 | }) 44 | 45 | it('should require address', () => { 46 | expect(() => mapping.addContract('hello', 1234, null, abi)).toThrow(/address not defined/) 47 | }) 48 | 49 | it('should require networkId', () => { 50 | expect(() => mapping.addContract('hello', null, '0x0CcCC7507aEDf9FEaF8C8D731421746e16b4d39D', abi)).toThrow(/networkId not defined/) 51 | }) 52 | 53 | it('should require abi', () => { 54 | expect(() => mapping.addContract('hello', 1234, '0x0CcCC7507aEDf9FEaF8C8D731421746e16b4d39D', null)).toThrow(/abi not defined/) 55 | }) 56 | 57 | it('should work', () => { 58 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 59 | 60 | expect(mapping.getContractAddress('Vouching', 1234)).toEqual('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e') 61 | }) 62 | 63 | it('should override', () => { 64 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 65 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 66 | 67 | expect(mapping.getContractAddress('Vouching', 1234)).toEqual('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e') 68 | 69 | mapping.addContract('Vouching', 1234, '0x51f595Ef681C3B3B6B6949FBbB36b7D98DAa15Bf', []) 70 | expect(mapping.getContractAddress('Vouching', 1234)).toEqual('0x51f595Ef681C3B3B6B6949FBbB36b7D98DAa15Bf') 71 | }) 72 | }) 73 | 74 | describe('getContractAbiDefinitionByAddress()', () => { 75 | it('should work', () => { 76 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 77 | 78 | expect(mapping.getContractAbiDefinitionByAddress('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', 1234)).toEqual(abiDef) 79 | }) 80 | 81 | it('should accept a bad address', () => { 82 | expect(mapping.getContractAbiDefinitionByAddress('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', 1234)).toEqual(undefined) 83 | }) 84 | 85 | it('should accept a bad networkId', () => { 86 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 87 | expect(mapping.getContractAbiDefinitionByAddress('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', 99)).toEqual(undefined) 88 | }) 89 | }) 90 | 91 | describe('getContractAbiDefinitionByName()', () => { 92 | it('should do nothing when the name does not exist', () => { 93 | expect(mapping.getContractAbiDefinitionByName('Vouching', 1234)).toEqual(undefined) 94 | }) 95 | 96 | it('should do nothing when the network id is wrong', () => { 97 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 98 | 99 | expect(mapping.getContractAbiDefinitionByName('Vouching', 1)).toEqual(undefined) 100 | }) 101 | 102 | it('should work', () => { 103 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 104 | 105 | expect(mapping.getContractAbiDefinitionByName('Vouching', 1234)).toEqual(abiDef) 106 | }) 107 | }) 108 | 109 | describe('getContractAddress()', () => { 110 | it('should do nothing when the address does not exist', () => { 111 | expect(mapping.getContractAddress('Vouching', 1234)).toEqual(undefined) 112 | }) 113 | 114 | it('should do nothing when the network id is wrong', () => { 115 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 116 | 117 | expect(mapping.getContractAddress('Vouching', 1)).toEqual(undefined) 118 | }) 119 | 120 | it('should work', () => { 121 | mapping.addContract('Vouching', 1234, '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', abi) 122 | expect(mapping.getContractAddress('Vouching', 1234)).toEqual('0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e') 123 | }) 124 | }) 125 | 126 | describe('addTruffleArtifact()', () => { 127 | it('should add the artifact', () => { 128 | mapping.addTruffleArtifact({ 129 | contractName: "Foo", 130 | abi, 131 | networks: { 132 | [1234]: { 133 | events: {}, 134 | links: {}, 135 | address: "0xdfC2FCFDE180eEd58B180Cbc50f4148f48581180", 136 | transactionHash: "" 137 | } 138 | } 139 | }) 140 | 141 | expect(mapping.getContractAddress('Foo', 1234)).toEqual('0xdfC2FCFDE180eEd58B180Cbc50f4148f48581180') 142 | }) 143 | }) 144 | }) 145 | -------------------------------------------------------------------------------- /src/abis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AbiDefinition' 2 | export * from './AbiMapping' -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as tightbeam from './tightbeam' 2 | 3 | export { tightbeam } 4 | 5 | export * from './typeDefs' 6 | export * from './tightbeam' -------------------------------------------------------------------------------- /src/multicall/Call.ts: -------------------------------------------------------------------------------- 1 | import { Arrayish } from 'ethers/utils' 2 | 3 | export class Call { 4 | constructor( 5 | public readonly to: string, 6 | public readonly data: Arrayish, 7 | public readonly resolve: Function, 8 | public readonly reject: Function) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/multicall/MulticallBatch.ts: -------------------------------------------------------------------------------- 1 | import { Call } from './Call' 2 | import { encodeCalls } from './encodeCalls' 3 | import { decodeCalls } from './decodeCalls' 4 | import { MulticallExecutor } from './MulticallExecutor' 5 | import { Arrayish } from 'ethers/utils' 6 | 7 | let nextId = 0 8 | 9 | const debug = require('debug')('tightbeam:MulticallBatch') 10 | 11 | export class MulticallBatch { 12 | private calls: Array = [] 13 | private batchSize: number = 0 14 | public readonly id: number = nextId++ 15 | 16 | constructor( 17 | private readonly executor: MulticallExecutor 18 | ) {} 19 | 20 | setBatchSize(batchSize: number) { 21 | this.batchSize = batchSize 22 | } 23 | 24 | async call(to: string, data: Arrayish) { 25 | let resolveCb: Function 26 | let rejectCb: Function 27 | const promise = new Promise((resolve, reject) => { 28 | resolveCb = resolve 29 | rejectCb = reject 30 | }) 31 | const call = new Call( 32 | to, 33 | data, 34 | resolveCb, 35 | rejectCb 36 | ) 37 | this.calls.push(call) 38 | 39 | if (this.atBatchSize()) { 40 | try { 41 | await this.execute() 42 | } catch (e) { 43 | console.error(e) 44 | this.calls.map(async (call) => call.reject(e.message)) 45 | } 46 | } 47 | 48 | return promise 49 | } 50 | 51 | atBatchSize() { 52 | return this.calls.length >= this.batchSize 53 | } 54 | 55 | async isSupported(): Promise { 56 | return await this.executor.networkSupportsMulticall() 57 | } 58 | 59 | async execute() { 60 | const data = encodeCalls(this.calls) 61 | debug('execute', { batchId: this.id }) 62 | 63 | let returnData = null 64 | 65 | try { 66 | returnData = await this.executor.execute(data) 67 | } catch (e) { 68 | console.warn(`executor.execute() failed`, e) 69 | } 70 | 71 | if (returnData) { 72 | const [blockNumber, returnValues] = decodeCalls(returnData) 73 | 74 | for (let i = 0; i < returnValues.length; i++) { 75 | this.calls[i].resolve(returnValues[i]) 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/multicall/MulticallExecutor.ts: -------------------------------------------------------------------------------- 1 | import { ProviderSource } from '../types' 2 | 3 | export const MULTICALL_ADDRESS_MAINNET = "0xeefba1e63905ef1d7acba5a8513c70307c1ce441" 4 | export const MULTICALL_ADDRESS_KOVAN = "0x2cc8688c5f75e365aaeeb4ea8d6a480405a48d2a" 5 | export const MULTICALL_ADDRESS_RINKEBY = "0x42ad527de7d4e9d9d011ac45b31d8551f8fe9821" 6 | export const MULTICALL_ADDRESS_GOERLI = "0x77dca2c955b15e9de4dbbcf1246b4b85b651e50e" 7 | export const AGGREGATE_SELECTOR = '0x252dba42'; 8 | 9 | export class MulticallExecutor { 10 | 11 | constructor( 12 | private readonly providerSource: ProviderSource 13 | ) {} 14 | 15 | async execute(data: string) { 16 | const address = await this.multicallAddressOrThrow() 17 | 18 | const callData = AGGREGATE_SELECTOR + data.substr(2) 19 | 20 | const tx = { 21 | to: address, 22 | data: callData 23 | } 24 | const provider = await this.providerSource() 25 | 26 | const result = await provider.call(tx) 27 | 28 | return result 29 | } 30 | 31 | async multicallAddressOrThrow() { 32 | const provider = await this.providerSource() 33 | const network = await provider.getNetwork() 34 | const address = this.multicallAddress(network.chainId) 35 | if (address === null) { 36 | const msg = `multicall is not available on the network ${network.chainId}` 37 | console.error(msg) 38 | throw new Error(msg) 39 | } 40 | return address 41 | } 42 | 43 | async networkSupportsMulticall() { 44 | const provider = await this.providerSource() 45 | const network = await provider.getNetwork() 46 | const address = this.multicallAddress(network.chainId) 47 | return address !== null 48 | } 49 | 50 | multicallAddress(chainId: number) { 51 | switch (chainId) { 52 | case 1: 53 | return MULTICALL_ADDRESS_MAINNET 54 | case 42: 55 | return MULTICALL_ADDRESS_KOVAN 56 | case 4: 57 | return MULTICALL_ADDRESS_RINKEBY 58 | case 5: 59 | return MULTICALL_ADDRESS_GOERLI 60 | default: 61 | return null 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/multicall/MulticallLink.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink } from 'apollo-link' 2 | import { countCalls } from './countCalls' 3 | import { ProviderSource } from '../types' 4 | import { MulticallBatch } from './MulticallBatch' 5 | import { MulticallExecutor } from './MulticallExecutor' 6 | 7 | const debug = require('debug')('tightbeam:MulticallLink') 8 | 9 | export class MulticallLink extends ApolloLink { 10 | private multicallExecutor: MulticallExecutor 11 | 12 | constructor(providerSource: ProviderSource) { 13 | super() 14 | this.multicallExecutor = new MulticallExecutor(providerSource) 15 | } 16 | 17 | request(operation, forward) { 18 | const multicallBatch = new MulticallBatch(this.multicallExecutor) 19 | const batchSize = countCalls(operation.query) 20 | debug(operation.operationName, { batchSize, batchId: multicallBatch.id }) 21 | multicallBatch.setBatchSize(batchSize) 22 | operation.setContext((context) => ({ 23 | multicallBatch, 24 | ...context 25 | })) 26 | const observer = forward(operation) 27 | return observer; 28 | } 29 | } -------------------------------------------------------------------------------- /src/multicall/__mocks__/decodeCalls.ts: -------------------------------------------------------------------------------- 1 | export const decodeCalls = jest.fn(() => 'decoded') -------------------------------------------------------------------------------- /src/multicall/__mocks__/encodeCalls.ts: -------------------------------------------------------------------------------- 1 | export const encodeCalls = jest.fn(() => 'encoded') -------------------------------------------------------------------------------- /src/multicall/__tests__/MulticallBatch.test.ts: -------------------------------------------------------------------------------- 1 | import { MulticallBatch } from '../MulticallBatch' 2 | const allSettled = require('promise.allsettled'); 3 | 4 | const { encodeCalls } = require('../encodeCalls') 5 | const { decodeCalls } = require('../decodeCalls') 6 | 7 | jest.mock('../encodeCalls') 8 | jest.mock('../decodeCalls') 9 | 10 | let decodeCallsResult 11 | function decodeCallsFactory() { 12 | return { 13 | decodeCalls: () => decodeCallsResult 14 | } 15 | } 16 | 17 | jest.mock('../decodeCalls', decodeCallsFactory) 18 | 19 | describe('MulticallBatch', () => { 20 | 21 | let batch 22 | 23 | let executor, execute 24 | 25 | beforeEach(async () => { 26 | execute = jest.fn(() => 'results') 27 | executor = { 28 | execute, 29 | networkSupportsMulticall: () => Promise.resolve(true) 30 | } 31 | batch = new MulticallBatch(executor) 32 | }) 33 | 34 | it('should execute immediately if batch size is not set', async () => { 35 | decodeCallsResult = [1, ['0xabcd decoded']] 36 | 37 | const to = '0x1234' 38 | const data = '0xabcd' 39 | 40 | const result = await batch.call(to, data) 41 | 42 | expect(result).toEqual('0xabcd decoded') 43 | expect(execute).toHaveBeenCalledTimes(1) 44 | expect(execute).toHaveBeenCalledWith('encoded') 45 | }) 46 | 47 | it('should correctly batch if multiple calls are passed', async () => { 48 | batch.setBatchSize(3) 49 | 50 | decodeCallsResult = [ 51 | 1, 52 | [ 53 | 'tx1 decoded', 54 | 'tx2 decoded', 55 | 'tx3 decoded' 56 | ] 57 | ] 58 | 59 | let to1 = '0x1' 60 | let data1 = 'tx1' 61 | 62 | let to2 = '0x2' 63 | let data2 = 'tx2' 64 | 65 | let to3 = '0x3' 66 | let data3 = 'tx3' 67 | 68 | const promise1 = batch.call(to1, data1) 69 | const promise2 = batch.call(to2, data2) 70 | const promise3 = batch.call(to3, data3) 71 | 72 | const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]) 73 | 74 | expect(result1).toEqual('tx1 decoded') 75 | expect(result2).toEqual('tx2 decoded') 76 | expect(result3).toEqual('tx3 decoded') 77 | 78 | expect(execute).toHaveBeenCalledTimes(1) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /src/multicall/__tests__/MulticallExecutor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MulticallExecutor, 3 | MULTICALL_ADDRESS_MAINNET, 4 | MULTICALL_ADDRESS_KOVAN, 5 | MULTICALL_ADDRESS_RINKEBY, 6 | MULTICALL_ADDRESS_GOERLI, 7 | AGGREGATE_SELECTOR 8 | } from '../MulticallExecutor' 9 | 10 | describe('MulticallExecutor', () => { 11 | 12 | let executor 13 | 14 | let provider, providerSource, chainId 15 | 16 | beforeEach(() => { 17 | chainId = 1 18 | provider = { 19 | call: jest.fn(), 20 | getNetwork: jest.fn(() => Promise.resolve({ 21 | chainId 22 | })) 23 | } 24 | providerSource = jest.fn(() => Promise.resolve(provider)) 25 | executor = new MulticallExecutor(providerSource) 26 | }) 27 | 28 | describe('execute()', () => { 29 | it('should execute against mainnet', async () => { 30 | chainId = 1 31 | await executor.execute('0x1234') 32 | expect(provider.call).toHaveBeenCalledWith({ 33 | to: MULTICALL_ADDRESS_MAINNET, 34 | data: `${AGGREGATE_SELECTOR}1234` 35 | }) 36 | }) 37 | 38 | it('should execute against kovan', async () => { 39 | chainId = 42 40 | await executor.execute('0x1234') 41 | expect(provider.call).toHaveBeenCalledWith({ 42 | to: MULTICALL_ADDRESS_KOVAN, 43 | data: `${AGGREGATE_SELECTOR}1234` 44 | }) 45 | }) 46 | 47 | it('should execute against rinkeby', async () => { 48 | chainId = 4 49 | await executor.execute('0x1234') 50 | expect(provider.call).toHaveBeenCalledWith({ 51 | to: MULTICALL_ADDRESS_RINKEBY, 52 | data: `${AGGREGATE_SELECTOR}1234` 53 | }) 54 | }) 55 | 56 | it('should execute against goerli', async () => { 57 | chainId = 5 58 | await executor.execute('0x1234') 59 | expect(provider.call).toHaveBeenCalledWith({ 60 | to: MULTICALL_ADDRESS_GOERLI, 61 | data: `${AGGREGATE_SELECTOR}1234` 62 | }) 63 | }) 64 | 65 | it('should fail with an unknown network', async () => { 66 | chainId = 888 67 | expect(executor.execute('0x1234')).rejects.toEqual(new Error('multicall is not available on the network 888')) 68 | }) 69 | }) 70 | 71 | describe('networkSupportsMulticall()', () => { 72 | it('should be true for mainnet', async () => { 73 | chainId = 1 74 | expect(await executor.networkSupportsMulticall()).toBeTruthy() 75 | }) 76 | 77 | it('should be false otherwise', async () => { 78 | chainId = 999 79 | expect(await executor.networkSupportsMulticall()).toBeFalsy() 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/multicall/__tests__/countCalls.test.ts: -------------------------------------------------------------------------------- 1 | import { countCalls } from '../countCalls' 2 | import gql from 'graphql-tag' 3 | 4 | describe('countCalls', () => { 5 | it('should count any @client directives whose resolver is call', async () => { 6 | 7 | const query = gql` 8 | query testQuery { 9 | alias: call(foo: "bar") @client 10 | } 11 | ` 12 | 13 | expect(countCalls(query)).toEqual(1) 14 | }) 15 | 16 | it('should not match resolvers missing the @client directive', async () => { 17 | 18 | const query = gql` 19 | query testQuery { 20 | alias: call(foo: "bar") 21 | } 22 | ` 23 | 24 | expect(countCalls(query)).toEqual(0) 25 | }) 26 | 27 | it('should not match resolvers missing the @client directive', async () => { 28 | 29 | const query = gql` 30 | query testQuery { 31 | call: call(foo: "bar") 32 | } 33 | ` 34 | 35 | expect(countCalls(query)).toEqual(0) 36 | }) 37 | }) -------------------------------------------------------------------------------- /src/multicall/__tests__/decodeCalls.test.ts: -------------------------------------------------------------------------------- 1 | import { decodeCalls } from '../decodeCalls' 2 | 3 | describe('decodeCalls', () => { 4 | it('should decode the return data', () => { 5 | 6 | }) 7 | }) -------------------------------------------------------------------------------- /src/multicall/__tests__/encodeCalls.test.ts: -------------------------------------------------------------------------------- 1 | import { encodeCalls } from '../encodeCalls' 2 | 3 | describe('encodeCalls', () => { 4 | 5 | 6 | it('should encode an address and calldata', () => { 7 | 8 | let call = { 9 | to: "0x6012fD40A66b993a28298838Be5C341956B5f7f4", 10 | data: "0x69e527da", 11 | resolve: jest.fn(), 12 | reject: jest.fn() 13 | } 14 | 15 | expect(encodeCalls([call])).toBeDefined() 16 | 17 | }) 18 | 19 | }) -------------------------------------------------------------------------------- /src/multicall/countCalls.ts: -------------------------------------------------------------------------------- 1 | import graphql from 'graphql-anywhere' 2 | 3 | export function countCalls(document) { 4 | const context = { 5 | count: 0 6 | } 7 | 8 | function resolver(fieldName, rootValue, args, context, info) { 9 | const { directives } = info 10 | if (fieldName === 'call' && directives && 'client' in directives) { 11 | context.count++ 12 | } 13 | } 14 | 15 | graphql(resolver, document, null, context) 16 | 17 | return context.count 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/multicall/decodeCalls.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | export function decodeCalls(returnData: string) { 4 | const [blockNumber, results] = ethers.utils.defaultAbiCoder.decode( 5 | ['uint256', 'bytes[]'], 6 | returnData 7 | ); 8 | return [blockNumber, results] 9 | } -------------------------------------------------------------------------------- /src/multicall/encodeCalls.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | import { Call } from './Call' 3 | 4 | export function encodeCalls(calls: Array): string { 5 | return ethers.utils.defaultAbiCoder.encode( 6 | [ 7 | { 8 | components: [{ type: 'address' }, { type: 'bytes' }], 9 | name: 'data', 10 | type: 'tuple[]' 11 | } 12 | ], 13 | [ 14 | calls.map(call => [ 15 | call.to, 16 | call.data 17 | ]) 18 | ] 19 | ); 20 | } -------------------------------------------------------------------------------- /src/queries/__tests__/sendTransactionMutation.test.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client' 2 | import { HttpLink } from 'apollo-link-http' 3 | import { InMemoryCache } from 'apollo-cache-inmemory'; 4 | import { sendTransactionMutation } from '../sendTransactionMutation' 5 | 6 | describe('sendTransactionMutation', () => { 7 | 8 | let client, cache, httpLink, sendTransaction 9 | 10 | beforeEach(() => { 11 | cache = new InMemoryCache() 12 | httpLink = new HttpLink({ 13 | uri: 'http://localhost:4000/', 14 | fetch: jest.fn() 15 | }); 16 | sendTransaction = jest.fn() 17 | 18 | client = new ApolloClient({ 19 | cache, 20 | link: httpLink, 21 | resolvers: { 22 | Mutation: { 23 | sendTransaction 24 | } 25 | } 26 | }) 27 | 28 | }) 29 | 30 | describe('write', () => { 31 | it('should record all of the data in a Transaction', async () => { 32 | const variables = { 33 | abi: 'abi', 34 | name: 'name', 35 | address: 'address', 36 | fn: 'fn', 37 | params: ['hello'], 38 | gasLimit: 32, 39 | value: 1, 40 | scaleGasEstimate: 1.1, 41 | minimumGas: 100000 42 | } 43 | 44 | await client.mutate({ 45 | mutation: sendTransactionMutation, 46 | variables 47 | }) 48 | 49 | expect(sendTransaction).toHaveBeenCalledWith( 50 | expect.anything(), 51 | expect.objectContaining(variables), 52 | expect.anything(), 53 | expect.anything() 54 | ) 55 | }) 56 | }) 57 | }) -------------------------------------------------------------------------------- /src/queries/accountQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const accountQuery = gql` 4 | query accountQuery { 5 | account @client 6 | } 7 | ` -------------------------------------------------------------------------------- /src/queries/allTransactionsQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import { transactionFragment } from './transactionFragment' 3 | 4 | /** 5 | * @param test Tests something! 6 | */ 7 | export const allTransactionsQuery = gql` 8 | query allTransactionsQuery { 9 | transactions @client(always: true) { 10 | ...transaction 11 | } 12 | } 13 | ${transactionFragment} 14 | ` 15 | -------------------------------------------------------------------------------- /src/queries/blockQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const blockQuery = gql` 4 | query blockQuery($blockNumber: Float!) { 5 | block(blockNumber: $blockNumber) @client 6 | } 7 | ` -------------------------------------------------------------------------------- /src/queries/cachedTransactionsQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import { transactionFragment } from './transactionFragment' 3 | 4 | export const cachedTransactionsQuery = gql` 5 | query cachedTransactionsQuery { 6 | _transactions @client(always: true) { 7 | ...transaction 8 | } 9 | } 10 | ${transactionFragment} 11 | ` 12 | -------------------------------------------------------------------------------- /src/queries/contractQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const contractQuery = gql` 4 | query contractQuery($name: String!) { 5 | contract(name: $name) @client(always: true) 6 | } 7 | ` -------------------------------------------------------------------------------- /src/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './accountQuery' 2 | export * from './allTransactionsQuery' 3 | export * from './blockQuery' 4 | export * from './cachedTransactionsQuery' 5 | export * from './transactionsQuery' 6 | export * from './networkQuery' 7 | export * from './sendTransactionMutation' 8 | export * from './transactionFragment' 9 | -------------------------------------------------------------------------------- /src/queries/networkQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const networkQuery = gql` 4 | query networkQuery { 5 | network @client { 6 | chainId 7 | name 8 | } 9 | } 10 | ` -------------------------------------------------------------------------------- /src/queries/sendTransactionMutation.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const sendTransactionMutation = gql` 4 | mutation sendTransactionMutation( 5 | $name: String!, 6 | $abi: String!, 7 | $address: String, 8 | $fn: String!, 9 | $params: Object!, 10 | $gasLimit: String, 11 | $gasPrice: String, 12 | $scaleGasEstimate: String, 13 | $value: String, 14 | $minimumGas: String 15 | ) { 16 | sendTransaction( 17 | name: $name, 18 | abi: $abi, 19 | address: $address, 20 | fn: $fn, 21 | params: $params, 22 | gasLimit: $gasLimit, 23 | gasPrice: $gasPrice, 24 | value: $value, 25 | scaleGasEstimate: $scaleGasEstimate, 26 | minimumGas: $minimumGas 27 | ) @client 28 | } 29 | ` 30 | -------------------------------------------------------------------------------- /src/queries/transactionFragment.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const transactionFragment = gql` 4 | fragment transaction on Transaction { 5 | id 6 | params { 7 | values 8 | } 9 | name 10 | abi 11 | address 12 | blockNumber 13 | completed 14 | error 15 | hash 16 | fn 17 | sent 18 | gasLimit 19 | gasPrice 20 | scaleGasEstimate 21 | minimumGas 22 | value 23 | } 24 | ` 25 | -------------------------------------------------------------------------------- /src/queries/transactionsQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import { transactionFragment } from './transactionFragment' 3 | 4 | export const transactionsQuery = gql` 5 | query transactionsQuery($id: String) { 6 | transactions(id: $id) @client(always: true) { 7 | ...transaction 8 | } 9 | } 10 | ${transactionFragment} 11 | ` 12 | -------------------------------------------------------------------------------- /src/resolvers/__tests__/bindMutationResolvers.test.ts: -------------------------------------------------------------------------------- 1 | const { bindMutationResolvers } = require('../bindMutationResolvers') 2 | const { sendTransactionResolver } = require('../mutations/sendTransactionResolver') 3 | jest.mock('../mutations/sendTransactionResolver') 4 | 5 | describe('bindMutationResolvers', () => { 6 | 7 | let mutationResolvers 8 | 9 | beforeEach(() => { 10 | mutationResolvers = bindMutationResolvers('contractCache', 'providerSource') 11 | }) 12 | 13 | describe('sendTransaction', () => { 14 | it('should build a function that proxies calls and iterates', () => { 15 | mutationResolvers.sendTransaction('opts', 'args', 'context', 'info') 16 | expect(sendTransactionResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 1, 'opts', 'args', 'context', 'info') 17 | 18 | mutationResolvers.sendTransaction('opts', 'args', 'context', 'info') 19 | expect(sendTransactionResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 2, 'opts', 'args', 'context', 'info') 20 | }) 21 | }) 22 | }) -------------------------------------------------------------------------------- /src/resolvers/__tests__/bindQueryResolvers.test.ts: -------------------------------------------------------------------------------- 1 | const { bindQueryResolvers } = require('../bindQueryResolvers') 2 | 3 | const queries = require('../queries') 4 | 5 | jest.mock('../queries') 6 | jest.mock('../mutations/sendTransactionResolver') 7 | 8 | describe('bindQueryResolvers', () => { 9 | 10 | let contractCache, providerSource, txProviderSource, defaultFromBlock 11 | 12 | beforeEach(() => { 13 | providerSource = 'providerSource' 14 | txProviderSource = 'txProviderSource' 15 | contractCache = 'contractCache' 16 | defaultFromBlock = 'defaultFromBlock' 17 | }) 18 | 19 | it('should bind account', () => { 20 | bindQueryResolvers(contractCache, providerSource, txProviderSource, defaultFromBlock).account('a', 'b', 'c', 'd') 21 | expect(queries.accountResolver).toHaveBeenCalledWith('txProviderSource') 22 | }) 23 | 24 | it('should bind block', () => { 25 | bindQueryResolvers(contractCache, providerSource, txProviderSource, defaultFromBlock).block('a', 'b', 'c', 'd') 26 | expect(queries.blockResolver).toHaveBeenCalledWith('providerSource', 'a', 'b', 'c', 'd') 27 | }) 28 | 29 | it('should bind call', () => { 30 | bindQueryResolvers(contractCache, providerSource, txProviderSource, defaultFromBlock).call('a', 'b', 'c', 'd') 31 | expect(queries.callResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 'a', 'b', 'c', 'd') 32 | }) 33 | 34 | it('should bind contract', () => { 35 | bindQueryResolvers(contractCache, providerSource, txProviderSource, defaultFromBlock).contract('a', 'b', 'c', 'd') 36 | expect(queries.contractResolver).toHaveBeenCalledWith('contractCache', 'a', 'b', 'c', 'd') 37 | }) 38 | 39 | it('should bind network', () => { 40 | bindQueryResolvers(contractCache, providerSource, txProviderSource, defaultFromBlock).network('a', 'b', 'c', 'd') 41 | expect(queries.networkResolver).toHaveBeenCalledWith('providerSource') 42 | }) 43 | 44 | it('should bind pastEvents', () => { 45 | bindQueryResolvers(contractCache, providerSource, txProviderSource, defaultFromBlock).pastEvents('a', 'b', 'c', 'd') 46 | expect(queries.pastEventsResolver).toHaveBeenCalledWith( 47 | "contractCache", 48 | "providerSource", 49 | "defaultFromBlock", 50 | 'a', 51 | 'b', 52 | 'c', 53 | 'd' 54 | ) 55 | }) 56 | }) -------------------------------------------------------------------------------- /src/resolvers/__tests__/bindResolvers.test.ts: -------------------------------------------------------------------------------- 1 | // Weird empty export because of: 2 | // https://medium.com/@muravitskiy.mail/cannot-redeclare-block-scoped-variable-varname-how-to-fix-b1c3d9cc8206 3 | export {}; 4 | 5 | const { bindMutationResolvers } = require('../bindMutationResolvers') 6 | const mutations = require('../mutations') 7 | 8 | jest.mock('../mutations') 9 | 10 | describe('bindMutationResolvers', () => { 11 | 12 | let mutationResolvers 13 | 14 | beforeEach(() => { 15 | mutationResolvers = bindMutationResolvers('contractCache', 'providerSource') 16 | }) 17 | 18 | describe('sendTransaction', () => { 19 | it('should build a function that proxies calls and iterates', () => { 20 | mutationResolvers.sendTransaction('opts', 'args', 'context', 'info') 21 | expect(mutations.sendTransactionResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 1, 'opts', 'args', 'context', 'info') 22 | 23 | mutationResolvers.sendTransaction('opts', 'args', 'context', 'info') 24 | expect(mutations.sendTransactionResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 2, 'opts', 'args', 'context', 'info') 25 | }) 26 | 27 | it('should not be affected by another instance', () => { 28 | const mr2 = bindMutationResolvers('contractCache', 'providerSource') 29 | mr2.sendTransaction('opts', 'args', 'context', 'info') 30 | expect(mutations.sendTransactionResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 1, 'opts', 'args', 'context', 'info') 31 | 32 | mutationResolvers.sendTransaction('opts', 'args', 'context', 'info') 33 | expect(mutations.sendTransactionResolver).toHaveBeenCalledWith('contractCache', 'providerSource', 1, 'opts', 'args', 'context', 'info') 34 | }) 35 | }) 36 | }) -------------------------------------------------------------------------------- /src/resolvers/bindMutationResolvers.ts: -------------------------------------------------------------------------------- 1 | import { ContractCache } from '../ContractCache' 2 | import { ProviderSource } from '../types' 3 | import { sendTransactionResolver } from './mutations' 4 | 5 | /** 6 | * 7 | * @param tightbeam The Tightbeam object 8 | */ 9 | export function bindMutationResolvers(contractCache: ContractCache, txProviderSource: ProviderSource) { 10 | let nextTxId = 1 11 | 12 | return { 13 | sendTransaction: function (opts, args, context, info) { 14 | return sendTransactionResolver(contractCache, txProviderSource, nextTxId++, opts, args, context, info) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/resolvers/bindQueryResolvers.ts: -------------------------------------------------------------------------------- 1 | import * as queries from './queries' 2 | import { ContractCache } from '../ContractCache' 3 | import { ProviderSource } from '../types' 4 | 5 | /** 6 | * 7 | * @param tightbeam The Tightbeam object 8 | * @returns A map of the query resolvers. 9 | */ 10 | export function bindQueryResolvers(contractCache: ContractCache, providerSource: ProviderSource, txProviderSource: ProviderSource, defaultFromBlock: number) { 11 | return { 12 | account: function () { 13 | return queries.accountResolver(txProviderSource) 14 | }, 15 | block: function (opts, args, context, info) { 16 | return queries.blockResolver( 17 | providerSource, 18 | opts, 19 | args, 20 | context, 21 | info 22 | ) 23 | }, 24 | call: function (opts, args, context, info) { 25 | return queries.callResolver( 26 | contractCache, 27 | providerSource, 28 | opts, 29 | args, 30 | context, 31 | info 32 | ) 33 | }, 34 | contract: function (opts, args, context, info) { 35 | return queries.contractResolver( 36 | contractCache, 37 | opts, 38 | args, 39 | context, 40 | info 41 | ) 42 | }, 43 | network: function () { 44 | return queries.networkResolver(providerSource) 45 | }, 46 | pastEvents: function (opts, args, context, info) { 47 | return queries.pastEventsResolver( 48 | contractCache, 49 | providerSource, 50 | defaultFromBlock, 51 | opts, 52 | args, 53 | context, 54 | info 55 | ) 56 | }, 57 | transaction: function (opts, args, context, info) { 58 | return queries.transactionResolver( 59 | opts, 60 | args, 61 | context, 62 | info 63 | ) 64 | }, 65 | transactions: function (opts, args, context, info) { 66 | return queries.transactionsResolver( 67 | opts, 68 | args, 69 | context, 70 | info 71 | ) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/resolvers/bindResolvers.ts: -------------------------------------------------------------------------------- 1 | import { bindMutationResolvers } from './bindMutationResolvers' 2 | import { bindQueryResolvers } from './bindQueryResolvers' 3 | import { Tightbeam } from '../Tightbeam' 4 | 5 | /** 6 | * 7 | * @param tightbeam The Tightbeam object 8 | */ 9 | export function bindResolvers(tightbeam: Tightbeam) { 10 | return { 11 | Query: bindQueryResolvers(tightbeam.contractCache, tightbeam.providerSource, tightbeam.txProviderSource, tightbeam.defaultFromBlock), 12 | Mutation: bindMutationResolvers(tightbeam.txContractCache, tightbeam.txProviderSource) 13 | } 14 | } -------------------------------------------------------------------------------- /src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import * as mutations from './mutations' 2 | import * as queries from './queries' 3 | 4 | export { mutations, queries } 5 | 6 | export * from './bindMutationResolvers' 7 | export * from './bindQueryResolvers' 8 | export * from './bindResolvers' -------------------------------------------------------------------------------- /src/resolvers/mutations/__tests__/sendTransactionResolver.test.ts: -------------------------------------------------------------------------------- 1 | import { transactionFragment } from '../../../queries' 2 | import { ethers } from 'ethers' 3 | 4 | const { sendTransactionResolver } = require('../sendTransactionResolver') 5 | 6 | jest.mock('../../../utils/castToJsonRpcProvider') 7 | jest.mock('../../../services/watchTransaction') 8 | 9 | let resolveSendUncheckedTransaction 10 | let rejectSendUncheckedTransaction 11 | let sendUncheckedTransactionPromise 12 | function sendUncheckedTransactionFactory() { 13 | return { 14 | sendUncheckedTransaction: () => sendUncheckedTransactionPromise 15 | } 16 | } 17 | 18 | jest.mock('../../../services/sendUncheckedTransaction', sendUncheckedTransactionFactory) 19 | 20 | const { watchTransaction } = require('../../../services/watchTransaction') 21 | 22 | const debug = require('debug')('tightbeam:sendTransactionResolver.test') 23 | 24 | describe('sendTransactionResolver', () => { 25 | let cache 26 | 27 | let contractCache, contract, provider, providerSource, signer 28 | 29 | beforeEach(() => { 30 | sendUncheckedTransactionPromise = new Promise((resolve, reject) => { 31 | resolveSendUncheckedTransaction = resolve 32 | rejectSendUncheckedTransaction = reject 33 | }) 34 | /** 35 | * 36 | * Need to mock: 37 | * 38 | * - contractCache.resolveContract() 39 | * - provider.getSigner() 40 | * - contract 41 | * - signer.sendUncheckedTransaction 42 | * 43 | */ 44 | 45 | cache = { 46 | readQuery: jest.fn(() => ({ _transactions: ['tx1'] })), 47 | writeData: jest.fn((data) => { debug('WRROOOOTE ', data)}), 48 | writeFragment: jest.fn(function () { debug('writeFragment: ', arguments)}), 49 | readFragment: jest.fn() 50 | } 51 | 52 | contractCache = { 53 | resolveContract: jest.fn(() => contract) 54 | } 55 | 56 | contract = { 57 | connect: jest.fn(() => contract), 58 | address: '0x1234', 59 | callMe: jest.fn(), 60 | interface: { 61 | functions: { 62 | callMe: { 63 | encode: jest.fn(() => 'encoded') 64 | } 65 | } 66 | }, 67 | estimate: { 68 | callMe: jest.fn(async () => ethers.utils.bigNumberify(222222)) 69 | } 70 | } 71 | 72 | 73 | 74 | signer = {} 75 | provider = { 76 | getSigner: jest.fn(() => signer) 77 | } 78 | 79 | providerSource = () => Promise.resolve(provider) 80 | }) 81 | 82 | it('should complain when the fn does not exist', async () => { 83 | await expect(sendTransactionResolver( 84 | contractCache, 85 | providerSource, 86 | 1, 87 | {}, 88 | { 89 | name: 'Dai', 90 | fn: 'badFn', 91 | params: [1, "hey there"] 92 | }, 93 | { cache }, 94 | {} 95 | )).rejects.toEqual(new Error('Unknown function badFn for {"name":"Dai","address":"0x1234"}')) 96 | }) 97 | 98 | it('should call a function when no value passed', async () => { 99 | const transaction = await sendTransactionResolver( 100 | contractCache, 101 | providerSource, 102 | 1, 103 | {}, 104 | { 105 | name: 'Dai', 106 | fn: 'callMe', 107 | gasPrice: ethers.utils.bigNumberify('12'), 108 | gasLimit: ethers.utils.bigNumberify('24'), 109 | scaleGasEstimate: ethers.utils.bigNumberify('36'), 110 | minimumGas: ethers.utils.bigNumberify('48'), 111 | params: [1, "yo"] 112 | }, 113 | { cache }, 114 | {} 115 | ) 116 | 117 | const tx = { 118 | __typename: 'Transaction', 119 | id: 1, 120 | fn: 'callMe', 121 | name: 'Dai', 122 | abi: null, 123 | address: '0x1234', 124 | completed: false, 125 | sent: false, 126 | hash: null, 127 | gasPrice: '12', 128 | gasLimit: '24', 129 | scaleGasEstimate: '36', 130 | minimumGas: '48', 131 | error: null, 132 | blockNumber: null, 133 | params: { 134 | values: ['1', 'yo'], 135 | __typename: 'JSON' 136 | }, 137 | value: null 138 | } 139 | 140 | expect(transaction).toMatchObject(tx) 141 | 142 | expect(cache.writeData).toHaveBeenCalledWith({ 143 | data: { 144 | _transactions: [ 145 | 'tx1', 146 | tx 147 | ] 148 | } 149 | }) 150 | 151 | await resolveSendUncheckedTransaction('hash') 152 | 153 | expect(cache.writeFragment).toHaveBeenCalledWith({ 154 | id: 'Transaction:1', 155 | fragment: transactionFragment, 156 | data: { 157 | ...tx, 158 | hash: 'hash', 159 | sent: true 160 | } 161 | }) 162 | }) 163 | 164 | it('should accept value param', async () => { 165 | const transaction = await sendTransactionResolver(contractCache, 166 | providerSource, 167 | 1, 168 | {}, 169 | { 170 | name: 'Dai', fn: 'callMe', params: [1, "hello"], value: ethers.utils.bigNumberify('12') 171 | }, 172 | { cache }, 173 | {} 174 | ) 175 | expect(transaction.value).toEqual('12') 176 | 177 | await resolveSendUncheckedTransaction('hash') 178 | 179 | expect(cache.writeFragment).toHaveBeenCalledWith({ 180 | id: 'Transaction:1', 181 | fragment: transactionFragment, 182 | data: { 183 | ...transaction, 184 | hash: 'hash', 185 | sent: true 186 | } 187 | }) 188 | }) 189 | 190 | it('should accept null params', async () => { 191 | const transaction = await sendTransactionResolver( 192 | contractCache, 193 | providerSource, 194 | 1, 195 | {}, 196 | { 197 | name: 'Dai', 198 | fn: 'callMe', 199 | params: null 200 | }, 201 | { cache }, 202 | {} 203 | ) 204 | expect(transaction.params).toEqual({ "__typename": "JSON", "values": [] }) 205 | }) 206 | 207 | it('should accept an abi', async () => { 208 | const transaction = await sendTransactionResolver(contractCache, 209 | providerSource, 210 | 1, 211 | {}, 212 | { 213 | abi: 'ERC20', 214 | address: '0xabcd', 215 | fn: 'callMe', 216 | params: [1, "what's up"], 217 | value: ethers.utils.bigNumberify('12') 218 | }, 219 | { cache }, 220 | {} 221 | ) 222 | expect(transaction.abi).toEqual('ERC20') 223 | expect(transaction.name).toEqual(null) 224 | }) 225 | 226 | it('should setup the tx as failed when an error occurs', async () => { 227 | const transaction = await sendTransactionResolver(contractCache, 228 | providerSource, 229 | 1, 230 | {}, 231 | { 232 | name: 'Dai', 233 | fn: 'callMe', 234 | params: [1, "g'day"], 235 | value: '12' 236 | }, 237 | { cache }, 238 | {} 239 | ) 240 | 241 | const tx = { 242 | __typename: 'Transaction', 243 | id: 1, 244 | fn: 'callMe', 245 | name: 'Dai', 246 | abi: null, 247 | address: '0x1234', 248 | completed: false, 249 | sent: false, 250 | hash: null, 251 | gasPrice: null, 252 | gasLimit: null, 253 | scaleGasEstimate: null, 254 | minimumGas: null, 255 | error: null, 256 | blockNumber: null, 257 | params: { 258 | values: ['1', "g'day"], 259 | __typename: 'JSON' 260 | }, 261 | value: '12' 262 | } 263 | 264 | expect(transaction).toMatchObject(tx) 265 | 266 | try { 267 | await rejectSendUncheckedTransaction(new Error('failmessage')) 268 | await sendUncheckedTransactionPromise 269 | } catch (e) { 270 | expect(e.message).toEqual('failmessage') 271 | } 272 | 273 | expect(cache.writeFragment).toHaveBeenCalledWith({ 274 | id: `Transaction:1`, 275 | fragment: transactionFragment, 276 | data: { 277 | ...tx, 278 | completed: true, 279 | sent: true, 280 | error: 'failmessage' 281 | } 282 | }) 283 | expect(watchTransaction).not.toHaveBeenCalled() 284 | }) 285 | }) -------------------------------------------------------------------------------- /src/resolvers/mutations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sendTransactionResolver' -------------------------------------------------------------------------------- /src/resolvers/mutations/sendTransactionResolver.ts: -------------------------------------------------------------------------------- 1 | import { cachedTransactionsQuery } from '../../queries' 2 | import { ContractCache } from '../../ContractCache' 3 | 4 | import { sendUncheckedTransaction } from '../../services/sendUncheckedTransaction' 5 | import { transactionFragment } from '../../queries' 6 | import { Transaction, TransactionParams } from '../../types/Transaction' 7 | import { ProviderSource } from '../../types/ProviderSource' 8 | import { castToJsonRpcProvider } from '../../utils/castToJsonRpcProvider' 9 | import { watchTransaction } from '../../services/watchTransaction' 10 | 11 | const debug = require('debug')('tightbeam:sendTransaction') 12 | 13 | export async function sendTransactionResolver(contractCache: ContractCache, providerSource: ProviderSource, txId: number, opts, args, context, info): Promise { 14 | const { cache } = context 15 | let { 16 | abi, 17 | name, 18 | address, 19 | fn, 20 | params, 21 | gasLimit, 22 | gasPrice, 23 | value, 24 | scaleGasEstimate, 25 | minimumGas 26 | } = args 27 | 28 | const provider = castToJsonRpcProvider(await providerSource()) 29 | 30 | const signer = provider.getSigner() 31 | 32 | let contract = await contractCache.resolveContract({ abi, address, name }) 33 | contract.connect(signer), 34 | address = contract.address 35 | 36 | const identifier = JSON.stringify({ abi, name, address }) 37 | 38 | if (!contract[fn]) { 39 | throw new Error(`Unknown function ${fn} for ${identifier}`) 40 | } 41 | 42 | if (!params) { 43 | params = [] 44 | } 45 | 46 | let newTx = new Transaction() 47 | 48 | newTx = { 49 | ...newTx, 50 | id: txId, 51 | fn, 52 | name: name || null, 53 | abi: abi || null, 54 | address, 55 | completed: false, 56 | sent: false, 57 | hash: null, 58 | error: null, 59 | blockNumber: null, 60 | gasLimit: gasLimit ? gasLimit.toString() : null, 61 | gasPrice: gasPrice ? gasPrice.toString() : null, 62 | scaleGasEstimate: scaleGasEstimate ? scaleGasEstimate.toString() : null, 63 | minimumGas: minimumGas ? minimumGas.toString() : null, 64 | value: value ? value.toString() : null, 65 | params: new TransactionParams(Array.from(params).map(param => param ? param.toString() : '')), 66 | } 67 | 68 | const query = cachedTransactionsQuery 69 | const data = cache.readQuery({ query }) 70 | 71 | cache.writeData({ 72 | // query, 73 | data: { 74 | _transactions: data._transactions.concat([newTx]) 75 | } 76 | }) 77 | 78 | const id = `Transaction:${newTx.id}` 79 | 80 | sendUncheckedTransaction(contractCache, providerSource, newTx) 81 | .then(hash => { 82 | const data = { 83 | ...newTx, 84 | hash, 85 | sent: true 86 | } 87 | debug(`Tx sent!`) 88 | 89 | cache.writeFragment({ 90 | id, 91 | fragment: transactionFragment, 92 | data 93 | }) 94 | 95 | watchTransaction(id, cache, provider) 96 | 97 | return data 98 | }) 99 | .catch(error => { 100 | console.error(error) 101 | debug(`Error occured while sending transaction`, error) 102 | 103 | const data = { 104 | ...newTx, 105 | completed: true, 106 | sent: true, 107 | error: error.message 108 | } 109 | 110 | cache.writeFragment({ 111 | id, 112 | fragment: transactionFragment, 113 | data 114 | }) 115 | 116 | return data 117 | }) 118 | 119 | return newTx 120 | } 121 | -------------------------------------------------------------------------------- /src/resolvers/queries/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | const accountResolver = jest.fn() 2 | const blockResolver = jest.fn() 3 | const callResolver = jest.fn() 4 | const contractResolver = jest.fn() 5 | const networkResolver = jest.fn() 6 | const pastEventsResolver = jest.fn() 7 | 8 | export { 9 | accountResolver, 10 | blockResolver, 11 | callResolver, 12 | contractResolver, 13 | networkResolver, 14 | pastEventsResolver 15 | } -------------------------------------------------------------------------------- /src/resolvers/queries/__tests__/accountResolver.test.ts: -------------------------------------------------------------------------------- 1 | const { accountResolver } = require('../accountResolver') 2 | 3 | jest.mock('../../../utils/castToJsonRpcProvider') 4 | 5 | describe('accountResolver', () => { 6 | 7 | let signer, provider, providerSource 8 | 9 | beforeEach(async () => { 10 | signer = { 11 | getAddress: jest.fn(() => Promise.resolve('0x1234')) 12 | } 13 | provider = { 14 | listAccounts: jest.fn(() => [1]), 15 | getSigner: jest.fn(() => signer) 16 | } 17 | providerSource = jest.fn(() => Promise.resolve(provider)) 18 | }) 19 | 20 | it('should work', async () => { 21 | expect(await accountResolver(providerSource)).toEqual('0x1234') 22 | }) 23 | 24 | it('should handle signers that are missing accounts', async () => { 25 | signer.getAddress = () => { throw 'unknown account #0' } 26 | expect(await accountResolver(providerSource)).toEqual(null) 27 | }) 28 | 29 | it('should handle when no accounts exist', async () => { 30 | provider.listAccounts = () => [] 31 | expect(await accountResolver(providerSource)).toEqual(null) 32 | }) 33 | }) -------------------------------------------------------------------------------- /src/resolvers/queries/__tests__/blockResolver.test.ts: -------------------------------------------------------------------------------- 1 | import { blockResolver } from '../blockResolver' 2 | import { Block } from '../../../types/Block' 3 | 4 | describe('blockResolver', () => { 5 | 6 | let provider, providerSource 7 | 8 | beforeEach(async () => { 9 | provider = { 10 | getBlock: jest.fn(() => ({ number: 42 })) 11 | } 12 | providerSource = jest.fn(() => Promise.resolve(provider)) 13 | }) 14 | 15 | it('should work', async () => { 16 | const block = await blockResolver(providerSource, {}, {}, {}, {}) 17 | expect(block.number).toEqual(42) 18 | expect(block.id).toEqual(42) 19 | }) 20 | }) -------------------------------------------------------------------------------- /src/resolvers/queries/__tests__/callResolver.test.ts: -------------------------------------------------------------------------------- 1 | import { callResolver } from '../callResolver' 2 | 3 | describe('callResolver()', () => { 4 | 5 | let contract, providerSource, contractCache, provider 6 | 7 | beforeEach(() => { 8 | contract = { 9 | address: 'my_addy', 10 | interface: { 11 | functions: { 12 | callMe: { 13 | outputs: [{ name: 'firstValue', type: 'uint256' }], 14 | encode: jest.fn(() => 'encoded'), 15 | decode: jest.fn(() => [42]) 16 | } 17 | } 18 | } 19 | } 20 | 21 | contractCache = { 22 | resolveContract: jest.fn(() => Promise.resolve(contract)) 23 | } 24 | 25 | provider = { 26 | call: jest.fn(() => Promise.resolve('encoded_value')) 27 | } 28 | 29 | providerSource = () => Promise.resolve(provider) 30 | }) 31 | 32 | it('should fail when fn does not exist', async () => { 33 | await expect(callResolver(contractCache, providerSource, {}, { name: 'ContractName', fn: 'funky' }, {}, {})).rejects.toEqual(new Error(`Unknown function funky for {"contract":"ContractName"}`)) 34 | }) 35 | 36 | it('should call when it works!', async () => { 37 | const result = await callResolver(contractCache, providerSource, {}, { name: 'ContractName', fn: 'callMe' }, {}, {}) 38 | expect(result).toEqual(42) 39 | expect(contract.interface.functions.callMe.encode).toHaveBeenCalledWith([]) 40 | expect(provider.call).toHaveBeenCalledWith(expect.objectContaining({data: 'encoded', to: 'my_addy'})) 41 | }) 42 | 43 | it('should handle the strange array result', async () => { 44 | contract.interface.functions.callMe.outputs = [{ name: 'firstValue', type: 'uint256' }, { name: 'secondValue', type: 'boolean' }] 45 | 46 | let weirdResult = ['test', 'foo'] 47 | // @ts-ignore 48 | weirdResult.firstValue = 'test' 49 | // @ts-ignore 50 | weirdResult.secondValue = 'foo' 51 | 52 | contract.interface.functions.callMe.decode = () => weirdResult 53 | 54 | const result = await callResolver(contractCache, providerSource, {}, { name: 'ContractName', fn: 'callMe' }, {}, {}) 55 | expect(result).toEqual(expect.objectContaining({ 56 | firstValue: 'test', 57 | secondValue: 'foo' 58 | })) 59 | }) 60 | 61 | it('should call with params', async () => { 62 | const result = await callResolver(contractCache, providerSource, {}, { name: 'ContractName', fn: 'callMe', params: ['foo'] }, {}, {}) 63 | expect(result).toEqual(42) 64 | expect(contract.interface.functions.callMe.encode).toHaveBeenCalledWith(['foo']) 65 | }) 66 | 67 | it('should gracefully handle errors', async () => { 68 | provider.call = () => Promise.reject('error!') 69 | await expect( 70 | callResolver(contractCache, providerSource, {}, { name: 'ContractName', fn: 'callMe', params: ['foo'] }, {}, {}) 71 | ).rejects.toEqual('{"contract":"ContractName"} callMe(["foo"]): error!') 72 | }) 73 | }) -------------------------------------------------------------------------------- /src/resolvers/queries/__tests__/contractResolver.test.ts: -------------------------------------------------------------------------------- 1 | const { contractResolver } = require('../contractResolver') 2 | 3 | describe('contractResolver', () => { 4 | 5 | let contract, contractCache 6 | 7 | beforeEach(async () => { 8 | contract = { 9 | address: '0x1234', 10 | provider: { 11 | getNetwork: jest.fn(async () => ({ chainId: 42 })) 12 | } 13 | } 14 | contractCache = { 15 | resolveContract: jest.fn(() => contract) 16 | } 17 | }) 18 | 19 | it('should work', async () => { 20 | const params = { name: 'ContractName', abi: 'callMe', address: '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e' } 21 | expect( 22 | await contractResolver(contractCache, {}, params, {}, {}) 23 | ).toEqual({ 24 | __typename: 'Contract', 25 | id: `42@0x1234`, 26 | name: 'ContractName', 27 | chainId: 42, 28 | address: '0x1234' 29 | }) 30 | 31 | expect(contractCache.resolveContract).toHaveBeenCalledWith(params) 32 | }) 33 | }) -------------------------------------------------------------------------------- /src/resolvers/queries/__tests__/networkResolvers.test.ts: -------------------------------------------------------------------------------- 1 | import { networkResolver } from '../networkResolver' 2 | 3 | describe('networkResolver', () => { 4 | 5 | let network, provider, providerSource 6 | 7 | beforeEach(async () => { 8 | network = { name: 'hello', chainId: 42 } 9 | provider = { 10 | getNetwork: jest.fn(() => network) 11 | } 12 | providerSource = jest.fn(() => Promise.resolve(provider)) 13 | }) 14 | 15 | it('should work', async () => { 16 | const network = await networkResolver(providerSource) 17 | expect(network).toMatchObject(network) 18 | }) 19 | }) -------------------------------------------------------------------------------- /src/resolvers/queries/__tests__/pastEventsResolver.test.ts: -------------------------------------------------------------------------------- 1 | const { pastEventsResolver } = require('../pastEventsResolver') 2 | 3 | jest.mock('../../../services/buildFilter') 4 | 5 | const { buildFilter } = require('../../../services/buildFilter') 6 | 7 | describe('pastEventsResolver', () => { 8 | 9 | let contract, 10 | contractCache, 11 | provider, 12 | providerSource, 13 | logs, 14 | defaultFromBlock 15 | 16 | beforeEach(async () => { 17 | logs = ['log1'] 18 | defaultFromBlock = 10 19 | provider = { 20 | getLogs: jest.fn(() => logs) 21 | } 22 | contract = { 23 | address: '0x1234', 24 | interface: { 25 | parseLog: jest.fn(() => 'parsedEvent') 26 | } 27 | } 28 | contractCache = { 29 | resolveContract: jest.fn(() => contract) 30 | } 31 | providerSource = jest.fn(() => 32 | Promise.resolve(provider) 33 | ) 34 | }) 35 | 36 | it('should work', async () => { 37 | const filter = { 38 | name: 'Foo', 39 | event: 'allEvents', 40 | address: '0x1234' 41 | } 42 | 43 | const pastEvents = await pastEventsResolver( 44 | contractCache, 45 | providerSource, 46 | defaultFromBlock, 47 | {}, 48 | filter 49 | ) 50 | 51 | expect(pastEvents).toEqual([ 52 | { 53 | log: 'log1', 54 | event: 'parsedEvent' 55 | } 56 | ]) 57 | 58 | expect(buildFilter).toHaveBeenCalledWith( 59 | '0x1234', 60 | contract.interface, 61 | { 62 | ...filter, 63 | fromBlock: 10 64 | } 65 | ) 66 | }) 67 | }) -------------------------------------------------------------------------------- /src/resolvers/queries/accountResolver.ts: -------------------------------------------------------------------------------- 1 | import { ProviderSource } from '../../types/ProviderSource' 2 | import { castToJsonRpcProvider } from '../../utils/castToJsonRpcProvider' 3 | 4 | const debug = require('debug')('tightbeam:web3Resolvers') 5 | 6 | /** 7 | * Resolvers execute the behaviour when an Apollo query with the same name is run. 8 | */ 9 | export async function accountResolver (providerSource: ProviderSource): Promise { 10 | try { 11 | const provider = castToJsonRpcProvider(await providerSource()) 12 | const accounts = await provider.listAccounts() 13 | if (!accounts.length) { return null } 14 | const signer = provider.getSigner() 15 | debug('signer: ', signer) 16 | const address = await signer.getAddress() 17 | debug('got address: ', address) 18 | return address 19 | } catch (e) { 20 | if(/JsonRpcProvider/.test(e.toString())) { 21 | return null 22 | } 23 | if (/unknown account #0/.test(e.toString())) { 24 | return null 25 | } 26 | throw e 27 | } 28 | } -------------------------------------------------------------------------------- /src/resolvers/queries/blockResolver.ts: -------------------------------------------------------------------------------- 1 | import { ProviderSource } from "../../types/ProviderSource" 2 | import { Block } from "../../types/Block" 3 | 4 | const debug = require('debug')('tightbeam:blockResolver') 5 | 6 | /** 7 | * Resolvers execute the behaviour when an Apollo query with the same name is run. 8 | */ 9 | export async function blockResolver (providerSource: ProviderSource, opts, args, context, info): Promise { 10 | const { 11 | blockNumber 12 | } = args 13 | 14 | const provider = await providerSource() 15 | debug('blockNumber: ', blockNumber) 16 | const block = await provider.getBlock(blockNumber) 17 | let result: Block 18 | result = { 19 | __typename: 'Block', 20 | id: block.number, 21 | ...block 22 | } 23 | debug(`block(${blockNumber}): `, result) 24 | return result 25 | } 26 | -------------------------------------------------------------------------------- /src/resolvers/queries/callResolver.ts: -------------------------------------------------------------------------------- 1 | import { ContractCache } from '../../ContractCache' 2 | import { ProviderSource } from '../../types/ProviderSource' 3 | 4 | const debug = require('debug')('tightbeam:callResolver') 5 | 6 | export async function callResolver(contractCache: ContractCache, providerSource: ProviderSource, opts, args, context, info) { 7 | // name and contract are the same- prefer 'contract', but 'name' is kept for backwards compatibility 8 | let { 9 | name, 10 | contract, 11 | abi, 12 | address, 13 | fn, 14 | params 15 | } = args 16 | 17 | params = params || [] 18 | 19 | const ethersContract = await contractCache.resolveContract({ abi, address, name, contract }) 20 | const identifier = JSON.stringify({ abi, address, contract: name || contract }) 21 | 22 | const provider = await providerSource() 23 | 24 | const fnCall = ethersContract.interface.functions[fn] 25 | if (!fnCall) { 26 | throw new Error(`Unknown function ${fn} for ${identifier}`) 27 | } else { 28 | try { 29 | const data = fnCall.encode(params) 30 | 31 | const tx = { 32 | data, 33 | to: ethersContract.address 34 | } 35 | 36 | debug({ identifier, fn, params }) 37 | 38 | let value 39 | if (context.multicallBatch && (await context.multicallBatch.isSupported())) { 40 | const to = await tx.to 41 | const data = await tx.data 42 | value = await context.multicallBatch.call(to, data) 43 | } else { 44 | value = await provider.call(tx) 45 | } 46 | 47 | let returns = fnCall.decode(value) 48 | if (fnCall.outputs.length === 1) { 49 | returns = returns[0]; 50 | } 51 | if (Array.isArray(returns)) { 52 | returns = Object.assign({}, returns) 53 | } 54 | 55 | return returns 56 | } catch (error) { 57 | const msg = `${identifier} ${fn}(${JSON.stringify(params)}): ${error.message || error}` 58 | console.warn(msg, error) 59 | throw msg 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/resolvers/queries/contractResolver.ts: -------------------------------------------------------------------------------- 1 | import { ContractCache } from '../../ContractCache' 2 | 3 | const debug = require('debug')('tightbeam:contractResolver') 4 | 5 | interface ContractType { 6 | __typename: string 7 | id: string 8 | name: string 9 | chainId: number 10 | address: string 11 | } 12 | 13 | export async function contractResolver(contractCache: ContractCache, opts, args, context, info): Promise { 14 | let { 15 | name, 16 | contract, 17 | address, 18 | abi 19 | } = args 20 | 21 | const ethersContract = await contractCache.resolveContract({ name, address, abi, contract }) 22 | 23 | if (!ethersContract) { 24 | const error = `tightbeam: contractResolver(): unknown contract ${JSON.stringify({ name, address, abi })}` 25 | debug(error) 26 | throw new Error(error) 27 | } 28 | 29 | const { chainId } = await ethersContract.provider.getNetwork() 30 | 31 | return { 32 | __typename: 'Contract', 33 | id: `${chainId}@${ethersContract.address}`, 34 | address: ethersContract.address, 35 | chainId, 36 | name 37 | } 38 | } -------------------------------------------------------------------------------- /src/resolvers/queries/index.ts: -------------------------------------------------------------------------------- 1 | export * from './accountResolver' 2 | export * from './blockResolver' 3 | export * from './callResolver' 4 | export * from './contractResolver' 5 | export * from './networkResolver' 6 | export * from './pastEventsResolver' 7 | export * from './transactionResolver' 8 | export * from './transactionsResolver' -------------------------------------------------------------------------------- /src/resolvers/queries/networkResolver.ts: -------------------------------------------------------------------------------- 1 | import { ProviderSource } from '../../types' 2 | 3 | const debug = require('debug')('tightbeam:networkResolver') 4 | 5 | /** 6 | * Resolvers execute the behaviour when an Apollo query with the same name is run. 7 | */ 8 | export const networkResolver = async function (providerSource: ProviderSource) { 9 | const provider = await providerSource() 10 | const network = await provider.getNetwork() 11 | const result = { 12 | __typename: 'Network', 13 | id: network.chainId, 14 | ...network 15 | } 16 | debug(result) 17 | return result 18 | } -------------------------------------------------------------------------------- /src/resolvers/queries/pastEventsResolver.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | import { ContractCache } from "../../ContractCache" 4 | import { buildFilter } from '../../services/buildFilter' 5 | import { ProviderSource } from "../../types/ProviderSource" 6 | import { EventFilter } from "../../types/EventFilter" 7 | import { LogEvent } from "../../types/LogEvent" 8 | 9 | const debug = require('debug')('tightbeam:pastEventsResolver') 10 | 11 | export async function pastEventsResolver( 12 | contractCache: ContractCache, 13 | providerSource: ProviderSource, 14 | defaultFromBlock: number, 15 | opts, 16 | eventFilter: EventFilter, 17 | context?, 18 | info? 19 | ): Promise> { 20 | 21 | debug(eventFilter) 22 | 23 | const contract = await contractCache.resolveContract({ 24 | abi: eventFilter.abi, 25 | address: eventFilter.address, 26 | name: eventFilter.name, 27 | contract: eventFilter.contract 28 | }) 29 | 30 | const filter = buildFilter( 31 | contract.address, 32 | contract.interface, 33 | { 34 | ...eventFilter, 35 | fromBlock: eventFilter.fromBlock || defaultFromBlock 36 | } 37 | ) 38 | 39 | const provider = providerSource ? 40 | await providerSource() : 41 | await ethers.getDefaultProvider() 42 | // const provider = await providerSource() 43 | 44 | const logs = await provider.getLogs(filter) 45 | 46 | return logs.map(log => ({ 47 | log, 48 | event: contract.interface.parseLog(log) 49 | })) 50 | } -------------------------------------------------------------------------------- /src/resolvers/queries/transactionResolver.ts: -------------------------------------------------------------------------------- 1 | import { transactionFragment } from '../../queries' 2 | import { Transaction } from '../../types/Transaction' 3 | 4 | export async function transactionResolver(opts, args, { cache, getCacheKey }, info): Promise { 5 | let { 6 | id 7 | } = args 8 | 9 | if (!id) { return null } 10 | 11 | let cacheKey = getCacheKey({ __typename: 'Transaction', id: id.toString() }) 12 | 13 | const result = await cache.readFragment({ 14 | id: cacheKey, 15 | fragment: transactionFragment 16 | }) 17 | 18 | return result 19 | } 20 | -------------------------------------------------------------------------------- /src/resolvers/queries/transactionsResolver.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from '../../types/Transaction' 2 | import { cachedTransactionsQuery } from '../../queries' 3 | 4 | const debug = require('debug')('tightbeam:transactionsResolver') 5 | 6 | export async function transactionsResolver(opts, args, { cache }, info): Promise> { 7 | let { 8 | id, 9 | name, 10 | address, 11 | fn 12 | } = args || {} 13 | 14 | debug('FIRING TXS RESOLVER! ', { id, 15 | name, 16 | address, 17 | fn }) 18 | 19 | const query = cachedTransactionsQuery 20 | const { _transactions } = cache.readQuery({ query }) 21 | 22 | const result = _transactions.filter(tx => { 23 | const matchId = !id || tx.id === id 24 | const matchName = !name || tx.name === name 25 | const matchAddress = !address || tx.address === address 26 | const matchFn = !fn || ((matchName || matchAddress) && tx.fn === fn) 27 | return matchId && matchName && matchAddress && matchFn 28 | }) 29 | 30 | debug('found: ', _transactions, result) 31 | 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /src/services/__mocks__/buildFilter.ts: -------------------------------------------------------------------------------- 1 | export const buildFilter = jest.fn() -------------------------------------------------------------------------------- /src/services/__mocks__/watchTransaction.ts: -------------------------------------------------------------------------------- 1 | export const watchTransaction = jest.fn() -------------------------------------------------------------------------------- /src/services/__tests__/buildFilter.test.ts: -------------------------------------------------------------------------------- 1 | import { buildFilter } from '../buildFilter' 2 | import { abi } from '../../__mocks__/abi' 3 | 4 | const { ethers } = jest.requireActual('ethers') 5 | 6 | describe('buildFilter', () => { 7 | 8 | let ethersInterface 9 | 10 | beforeEach(() => { 11 | ethersInterface = new ethers.utils.Interface(abi) 12 | }) 13 | 14 | it('should accept specific event names', () => { 15 | 16 | const filter = buildFilter( 17 | '0x1234', 18 | ethersInterface, 19 | { 20 | event: 'Transfer' 21 | } 22 | ) 23 | 24 | expect(filter).toMatchObject({ 25 | address: '0x1234', 26 | fromBlock: 0, 27 | toBlock: 'latest', 28 | topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'] 29 | }) 30 | }) 31 | 32 | it('should accept allEvents', () => { 33 | 34 | const filter = buildFilter( 35 | '0x1234', 36 | ethersInterface, 37 | { 38 | event: 'allEvents', 39 | } 40 | ) 41 | 42 | expect(filter).toMatchObject({ 43 | address: '0x1234', 44 | fromBlock: 0, 45 | toBlock: 'latest', 46 | topics: [null] 47 | }) 48 | }) 49 | 50 | it('should accept null event as all events', () => { 51 | 52 | const filter = buildFilter( 53 | '0x1234', 54 | ethersInterface, 55 | {} 56 | ) 57 | 58 | expect(filter).toMatchObject({ 59 | address: '0x1234', 60 | fromBlock: 0, 61 | toBlock: 'latest', 62 | topics: [null] 63 | }) 64 | }) 65 | 66 | it('should accept passed topics', () => { 67 | 68 | const filter = buildFilter( 69 | '0x1234', 70 | ethersInterface, 71 | { 72 | topics: { 73 | types: ['uint256'], 74 | values: ['4234'] 75 | } 76 | } 77 | ) 78 | 79 | expect(filter).toMatchObject({ 80 | address: '0x1234', 81 | fromBlock: 0, 82 | toBlock: 'latest', 83 | topics: ['0x000000000000000000000000000000000000000000000000000000000000108a'] 84 | }) 85 | }) 86 | 87 | it('should accept the toBlock', () => { 88 | 89 | const filter = buildFilter( 90 | '0x1234', 91 | ethersInterface, 92 | { 93 | toBlock: 1234 94 | } 95 | ) 96 | 97 | expect(filter).toMatchObject({ 98 | address: '0x1234', 99 | fromBlock: 0, 100 | toBlock: 1234, 101 | topics: [null] 102 | }) 103 | }) 104 | 105 | it('should accept extra topics and append them', () => { 106 | 107 | const filter = buildFilter( 108 | '0x1234', 109 | ethersInterface, 110 | { 111 | toBlock: 1234, 112 | extraTopics: { 113 | types: ['uint256'], 114 | values: ['4234'] 115 | } 116 | } 117 | ) 118 | 119 | expect(filter).toMatchObject({ 120 | address: '0x1234', 121 | fromBlock: 0, 122 | toBlock: 1234, 123 | topics: [null, '0x000000000000000000000000000000000000000000000000000000000000108a'] 124 | }) 125 | }) 126 | 127 | it('should fail when the event doesnt exist', () => { 128 | expect(() => { 129 | buildFilter( 130 | '0x1234', 131 | ethersInterface, 132 | { 133 | event: 'asdf' 134 | } 135 | ) 136 | }).toThrow(new Error('No event called asdf')) 137 | }) 138 | }) -------------------------------------------------------------------------------- /src/services/__tests__/watchTransaction.test.ts: -------------------------------------------------------------------------------- 1 | import { watchTransaction } from '../watchTransaction' 2 | import { Transaction } from '../../types/Transaction' 3 | import { transactionFragment } from '../../queries' 4 | 5 | describe('watchTransaction()', () => { 6 | let tx, receipt, client, provider 7 | 8 | beforeEach(() => { 9 | tx = new Transaction() 10 | tx.id = 1 11 | tx.hash = 'hellohash' 12 | 13 | client = { 14 | readFragment: jest.fn(() => tx), 15 | writeFragment: jest.fn() 16 | } 17 | 18 | receipt = { status: 1, blockNumber: 42 } 19 | 20 | provider = { 21 | waitForTransaction: jest.fn(() => Promise.resolve('ok!')), 22 | getTransactionReceipt: jest.fn(() => Promise.resolve(receipt)) 23 | } 24 | }) 25 | 26 | it('should update on successful tx', async () => { 27 | await watchTransaction('Transaction:1', client, provider) 28 | expect(client.writeFragment).toHaveBeenCalledWith({ 29 | fragment: transactionFragment, 30 | id: 'Transaction:1', 31 | data: { 32 | ...tx, 33 | completed: true, 34 | blockNumber: 42 35 | } 36 | }) 37 | }) 38 | 39 | it('should update on failure', async () => { 40 | receipt = { status: 0 } 41 | await watchTransaction('Transaction:1', client, provider) 42 | expect(client.writeFragment).toHaveBeenCalledWith({ 43 | fragment: transactionFragment, 44 | id: 'Transaction:1', 45 | data: { 46 | ...tx, 47 | completed: true, 48 | error: 'Status is 0' 49 | } 50 | }) 51 | }) 52 | 53 | it('should update on network failure', async () => { 54 | provider.waitForTransaction = jest.fn(() => Promise.reject('big problemo')) 55 | await watchTransaction('Transaction:1', client, provider) 56 | expect(client.writeFragment).toHaveBeenCalledWith({ 57 | fragment: transactionFragment, 58 | id: 'Transaction:1', 59 | data: { 60 | ...tx, 61 | completed: true, 62 | error: 'Failed getting receipt: big problemo' 63 | } 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/services/buildFilter.ts: -------------------------------------------------------------------------------- 1 | import { encodeEventTopics } from '../utils/encodeEventTopics' 2 | import { ethers } from 'ethers' 3 | import { EventFilter } from '../types' 4 | 5 | const debug = require('debug')('tightbeam:buildFilter') 6 | 7 | export function buildFilter(address: string, ethersInterface: ethers.utils.Interface, filterParams: EventFilter): ethers.providers.Filter { 8 | const { 9 | params, 10 | topics, 11 | event, 12 | extraTopics, 13 | fromBlock, 14 | toBlock 15 | } = filterParams 16 | 17 | let actualTopics = [] 18 | if (topics) { 19 | actualTopics = encodeEventTopics(topics) 20 | } else if (!event || event === 'allEvents') { 21 | actualTopics = [null] 22 | } else { 23 | let eventInterface = ethersInterface.events[event] 24 | if (!eventInterface) { throw new Error(`No event called ${event}`)} 25 | actualTopics = eventInterface.encodeTopics(params || []) 26 | debug(`using topics for ${event}`, topics) 27 | } 28 | 29 | let encodedExtraTopics = [] 30 | if (extraTopics) { 31 | encodedExtraTopics = encodeEventTopics(extraTopics) 32 | } 33 | 34 | const filter = { 35 | address, 36 | fromBlock: fromBlock || 0, 37 | toBlock: toBlock || 'latest', 38 | topics: actualTopics.concat(encodedExtraTopics) 39 | } 40 | 41 | return filter 42 | } -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './buildFilter' 2 | export * from './sendUncheckedTransaction' 3 | export * from './watchTransaction' -------------------------------------------------------------------------------- /src/services/sendUncheckedTransaction.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | import { ContractCache } from '../ContractCache' 4 | import { Transaction } from '../types/Transaction' 5 | import { ProviderSource } from '../types/ProviderSource' 6 | import { gasCalculator } from '../utils' 7 | import { castToJsonRpcProvider } from '../utils/castToJsonRpcProvider' 8 | 9 | const debug = require('debug')('tightbeam:sendUncheckedTransaction') 10 | 11 | export async function sendUncheckedTransaction( 12 | contractCache: ContractCache, 13 | providerSource: ProviderSource, 14 | tx: Transaction 15 | ): Promise { 16 | let { 17 | abi, 18 | address, 19 | name, 20 | fn, 21 | params, 22 | gasLimit, 23 | gasPrice, 24 | scaleGasEstimate, 25 | minimumGas, 26 | value 27 | } = tx 28 | 29 | const provider = castToJsonRpcProvider(await providerSource()) 30 | 31 | const signer = provider.getSigner() 32 | 33 | let contract = await contractCache.resolveContract({ abi, address, name }) 34 | contract = contract.connect(signer) 35 | address = contract.address 36 | 37 | let estimatedGasLimit = null 38 | try { 39 | estimatedGasLimit = await contract.estimate[tx.fn](...params.values) 40 | } catch (e) { 41 | console.log(`Error while estimating gas: ${e}`) 42 | } 43 | let selectedGasLimit = gasCalculator(gasLimit, estimatedGasLimit, scaleGasEstimate, minimumGas) 44 | 45 | const transactionData = contract.interface.functions[fn].encode(params.values) 46 | 47 | const unsignedTransaction = { 48 | data: transactionData, 49 | to: contract.address, 50 | gasLimit: selectedGasLimit 51 | } 52 | 53 | if (value) { 54 | // @ts-ignore 55 | unsignedTransaction.value = ethers.utils.bigNumberify(value) 56 | } 57 | 58 | if (gasPrice) { 59 | // @ts-ignore 60 | unsignedTransaction.gasPrice = ethers.utils.bigNumberify(gasPrice) 61 | } 62 | 63 | 64 | let selectedGasLimitString 65 | if (selectedGasLimit) { 66 | selectedGasLimitString = selectedGasLimit.toString() 67 | } 68 | debug( 69 | `ID: ${tx.id}\n 70 | ContractAddress: ${address}\n 71 | ContractMethod: ${fn}\n 72 | TransactionParams: `, JSON.stringify(params), `\n\n 73 | with gasLimit ${selectedGasLimitString}\n\n 74 | unsignedTransaction: `, JSON.stringify(unsignedTransaction)) 75 | 76 | 77 | return await signer.sendUncheckedTransaction(unsignedTransaction) 78 | } -------------------------------------------------------------------------------- /src/services/watchNetworkAndAccount.ts: -------------------------------------------------------------------------------- 1 | import { accountQuery } from '../queries/accountQuery' 2 | import { networkIdQuery } from '../queries/networkIdQuery' 3 | import { ethereumPermissionQuery } from '../queries/ethereumPermissionQuery' 4 | 5 | /** 6 | * Creates Apollo GraphQL subscriptions to watch for changes to the web3 7 | * browser network and refresh the page when an account or network is changed 8 | * 9 | * @returns {undefined} 10 | */ 11 | export function watchNetworkAndAccount (apolloClient) { 12 | // If the user signs in to MetaMask or logs out, we should ... (refresh the page?) 13 | apolloClient.watchQuery({ 14 | query: accountQuery, 15 | pollInterval: 3000, 16 | fetchPolicy: 'network-only' 17 | }).subscribe() 18 | 19 | // This subscription listens for changes to a web3 browser (ie metamask's) network 20 | apolloClient.watchQuery({ 21 | query: networkIdQuery, 22 | pollInterval: 3000, 23 | fetchPolicy: 'network-only' 24 | }).subscribe() 25 | 26 | apolloClient.watchQuery({ 27 | query: ethereumPermissionQuery, 28 | pollInterval: 3000, 29 | fetchPolicy: 'network-only' 30 | }).subscribe() 31 | } 32 | -------------------------------------------------------------------------------- /src/services/watchTransaction.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from 'lodash' 2 | import { transactionFragment } from '../queries' 3 | import { ethers } from 'ethers' 4 | 5 | const debug = require('debug')('tightbeam:watchTransaction') 6 | 7 | export async function watchTransaction(id: string, client, provider: ethers.providers.BaseProvider): Promise { 8 | const readTxFragment = () => { 9 | return client.readFragment({ fragment: transactionFragment, id }) 10 | } 11 | 12 | let transaction = readTxFragment() 13 | const { hash } = transaction 14 | 15 | try { 16 | await provider.waitForTransaction(hash) 17 | const receipt = await provider.getTransactionReceipt(hash) 18 | 19 | let tx = readTxFragment() 20 | tx = cloneDeep(tx) 21 | if (receipt.status === 0) { 22 | tx = { ...tx, completed: true, error: `Status is 0` } 23 | debug(`Ethereum tx had a 0 status. Tx hash: ${hash}`) 24 | } else { 25 | tx = { ...tx, completed: true, blockNumber: receipt.blockNumber } 26 | } 27 | 28 | client.writeFragment({ 29 | id, 30 | fragment: transactionFragment, 31 | data: tx 32 | }) 33 | } catch (error) { 34 | console.error(error) 35 | let tx = readTxFragment() 36 | tx = { ...tx, completed: true, error: `Failed getting receipt: ${error}` } 37 | client.writeFragment({ 38 | id, 39 | fragment: transactionFragment, 40 | data: tx 41 | }) 42 | debug(`Unable to get transaction receipt for tx with hash: ${hash} - `, error) 43 | } 44 | } -------------------------------------------------------------------------------- /src/subscribers/BlockSubscriptionManager.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'zen-observable-ts' 2 | import { ProviderSource } from "../types"; 3 | 4 | const debug = require('debug')('tightbeam:BlockSubscriptionManager') 5 | 6 | export class BlockSubscriptionManager { 7 | public readonly observers: Array = [] 8 | 9 | constructor (public readonly providerSource: ProviderSource) { 10 | } 11 | 12 | async start() { 13 | const provider = await this.providerSource() 14 | provider.on('block', this.notify) 15 | } 16 | 17 | async stop() { 18 | const provider = await this.providerSource() 19 | provider.removeListener('block', this.notify) 20 | } 21 | 22 | notify = async (blockNumber) => { 23 | debug(`notify(${blockNumber})`) 24 | this.observers.forEach(observer => { 25 | observer.next(blockNumber) 26 | }) 27 | } 28 | 29 | async subscribe(): Promise> { 30 | return new Observable(observer => { 31 | this.addObserver(observer) 32 | return () => { this.removeObserver(observer) } 33 | }) 34 | } 35 | 36 | addObserver(observer) { 37 | this.observers.push(observer) 38 | } 39 | 40 | removeObserver(observer) { 41 | const index = this.observers.findIndex(observer) 42 | if (index > -1) { 43 | this.observers.splice(index, 1) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/subscribers/EventSubscriptionManager.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'zen-observable-ts' 2 | import { ProviderSource } from "../types"; 3 | import { Log } from 'ethers/providers'; 4 | import { BlockSubscriptionManager } from './BlockSubscriptionManager'; 5 | 6 | const debug = require('debug')('tightbeam:EventSubscriptionManager') 7 | 8 | export class EventSubscriptionManager { 9 | public readonly observers: Array = [] 10 | private unsubscribe: any 11 | 12 | constructor ( 13 | public readonly providerSource: ProviderSource, 14 | public readonly blockSubscriptionManager: BlockSubscriptionManager 15 | ) { 16 | } 17 | 18 | async start() { 19 | const provider = await this.providerSource() 20 | const observable = await this.blockSubscriptionManager.subscribe() 21 | this.unsubscribe = observable.subscribe(async (blockNumber) => { 22 | const logs = await provider.getLogs({ 23 | fromBlock: blockNumber, 24 | toBlock: blockNumber 25 | }) 26 | this.notify(logs) 27 | }) 28 | } 29 | 30 | async stop() { 31 | if (this.unsubscribe) { 32 | this.unsubscribe() 33 | } 34 | } 35 | 36 | notify = async (logs: Log[]) => { 37 | this.observers.forEach(observer => { 38 | observer.next(logs) 39 | }) 40 | } 41 | 42 | async subscribe(): Promise>> { 43 | return new Observable>(observer => { 44 | this.addObserver(observer) 45 | return () => { this.removeObserver(observer) } 46 | }) 47 | } 48 | 49 | addObserver(observer) { 50 | this.observers.push(observer) 51 | } 52 | 53 | removeObserver(observer) { 54 | const index = this.observers.indexOf(observer) 55 | if (index > -1) { 56 | this.observers.splice(index, 1) 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/subscribers/__tests__/eventSubscriber.test.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | const { buildFilter } = require('../../services/buildFilter') 4 | const { eventSubscriber } = require('../eventSubscriber') 5 | 6 | jest.mock('../../services/buildFilter') 7 | 8 | describe('eventSubscriber', () => { 9 | 10 | let contract, contractCache, logsObserver, observer, filter 11 | let next 12 | 13 | beforeEach(() => { 14 | contract = { 15 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6', 16 | interface: { 17 | parseLog: jest.fn(() => 'parsedLog') 18 | } 19 | } 20 | contractCache = { 21 | resolveContract: jest.fn(() => contract) 22 | } 23 | filter = { 24 | address: '0x8f7F92e0660DD92ecA1faD5F285C4Dca556E433e', 25 | topics: [] 26 | } 27 | logsObserver = { 28 | subscribe: jest.fn((o) => { 29 | observer = o 30 | return 'unsubscribe' 31 | }) 32 | } 33 | next = jest.fn() 34 | buildFilter.mockImplementation(() => filter) 35 | }) 36 | 37 | it('should call resolveContract, build filter, then subscribe ', async () => { 38 | const subscriber = await eventSubscriber(contractCache, logsObserver, { name: 'Dai' }) 39 | subscriber.subscribe(next) 40 | 41 | expect(contractCache.resolveContract).toHaveBeenCalledWith(expect.objectContaining({ name: 'Dai' })) 42 | 43 | expect(buildFilter).toHaveBeenCalledWith( 44 | '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6', 45 | contract.interface, 46 | { 47 | name: 'Dai' 48 | } 49 | ) 50 | 51 | expect(logsObserver.subscribe).toHaveBeenCalledTimes(1) 52 | expect(observer).toBeDefined() 53 | }) 54 | 55 | it('should ignore logs with incorrect addresses', async () => { 56 | const subscriber = await eventSubscriber(contractCache, logsObserver, { name: 'Dai' }) 57 | subscriber.subscribe(next) 58 | 59 | // defined by the circuitous logsObserver.subscribe 60 | observer([ 61 | { 62 | address: '0x0CcCC7507aEDf9FEaF8C8D731421746e16b4d39D', 63 | topics: [] 64 | } 65 | ]) 66 | 67 | expect(next).not.toHaveBeenCalled() 68 | }) 69 | 70 | it('should call next for logs with matching addresses', async () => { 71 | filter = { 72 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44A6', 73 | topics: [null] 74 | } 75 | 76 | const subscriber = await eventSubscriber(contractCache, logsObserver, { name: 'Dai' }) 77 | subscriber.subscribe(next) 78 | 79 | const matchingLog = { 80 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643CEd44a6', 81 | topics: [] 82 | } 83 | 84 | // defined by the circuitous logsObserver.subscribe 85 | observer([ 86 | matchingLog 87 | ]) 88 | 89 | expect(next).toHaveBeenCalledWith({ 90 | event: 'parsedLog', 91 | log: matchingLog 92 | }) 93 | }) 94 | 95 | it('should not call next for logs with mismatched topics', async () => { 96 | filter = { 97 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6', 98 | topics: ['i am a specific fn'] 99 | } 100 | 101 | const subscriber = await eventSubscriber(contractCache, logsObserver, { name: 'Dai' }) 102 | subscriber.subscribe(next) 103 | 104 | const matchingLog = { 105 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6', 106 | topics: [] 107 | } 108 | 109 | // defined by the circuitous logsObserver.subscribe 110 | observer([ 111 | matchingLog 112 | ]) 113 | 114 | expect(next).not.toHaveBeenCalled() 115 | }) 116 | 117 | it('should call next for logs with matching topics', async () => { 118 | filter = { 119 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6', 120 | topics: ['i am a specific fn', undefined] 121 | } 122 | 123 | const subscriber = await eventSubscriber(contractCache, logsObserver, { name: 'Dai' }) 124 | subscriber.subscribe(next) 125 | 126 | const matchingLog = { 127 | address: '0xD115BFFAbbdd893A6f7ceA402e7338643Ced44a6', 128 | topics: ['i am a specific fn'] 129 | } 130 | 131 | // defined by the circuitous logsObserver.subscribe 132 | observer([ 133 | matchingLog 134 | ]) 135 | 136 | expect(next).toHaveBeenCalledWith({ 137 | event: 'parsedLog', 138 | log: matchingLog 139 | }) 140 | }) 141 | }) -------------------------------------------------------------------------------- /src/subscribers/blockSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'zen-observable-ts' 2 | import { ProviderSource } from "../types"; 3 | 4 | export async function blockSubscriber(providerSource: ProviderSource): Promise> { 5 | // const provider = await providerSource() 6 | 7 | // return new Observable(observer => { 8 | // const cb = (blockNumber) => { 9 | // observer.next(blockNumber) 10 | // } 11 | // provider.on('block', cb) 12 | // return () => provider.removeListener('block', cb) 13 | // }) 14 | return null 15 | } -------------------------------------------------------------------------------- /src/subscribers/eventSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'zen-observable-ts' 2 | import { ContractCache } from '../ContractCache' 3 | import { EventFilter } from '../types/EventFilter' 4 | import { buildFilter } from '../services/buildFilter' 5 | import { LogEvent } from '../types/LogEvent' 6 | import { Log } from 'ethers/providers' 7 | 8 | const debug = require('debug')('tightbeam:eventSubscriber') 9 | 10 | export async function eventSubscriber( 11 | contractCache: ContractCache, 12 | logsObserver: Observable>, 13 | eventFilter: EventFilter 14 | ): Promise> { 15 | 16 | 17 | const contract = await contractCache.resolveContract({ 18 | abi: eventFilter.abi, 19 | address: eventFilter.address, 20 | name: eventFilter.name, 21 | contract: eventFilter.contract 22 | }) 23 | 24 | const filter = buildFilter( 25 | contract.address, 26 | contract.interface, 27 | eventFilter 28 | ) 29 | 30 | debug(eventFilter, filter) 31 | 32 | return new Observable(observer => { 33 | const unsubscribe = logsObserver.subscribe((logs) => { 34 | for (let i = 0; i < logs.length; i++) { 35 | const log = logs[i] 36 | 37 | const addressMatch = !filter.address || filter.address.toLowerCase() === log.address.toLowerCase() 38 | 39 | if (!addressMatch) { 40 | continue 41 | } 42 | 43 | const topicsMatch = filter.topics.reduce((isMatch, filterTopic, currentIndex) => { 44 | return isMatch && ( 45 | filterTopic === null || 46 | filterTopic === undefined || 47 | filterTopic === log.topics[currentIndex] 48 | ) 49 | }, true) 50 | 51 | if (!topicsMatch) { 52 | continue 53 | } 54 | 55 | const event = { 56 | log, 57 | event: contract.interface.parseLog(log) 58 | } 59 | debug(`filter received `, filter, event) 60 | observer.next(event) 61 | } 62 | }) 63 | 64 | return unsubscribe 65 | }) 66 | } -------------------------------------------------------------------------------- /src/subscribers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './eventSubscriber' 2 | export * from './BlockSubscriptionManager' 3 | export * from './EventSubscriptionManager' -------------------------------------------------------------------------------- /src/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const typeDefs = gql` 4 | type TransactionParams { 5 | values: [String] 6 | } 7 | 8 | type Transaction { 9 | id: ID! 10 | fn: String 11 | name: String 12 | abi: String 13 | address: String 14 | completed: Boolean 15 | sent: Boolean 16 | hash: String 17 | error: String 18 | gasLimit: String 19 | gasPrice: String 20 | scaleGasEstimate: String 21 | minimumGas: String 22 | blockNumber: Float 23 | params: TransactionParams 24 | value: String 25 | } 26 | 27 | extend type Query { 28 | transactions(id: String): [Transaction] 29 | } 30 | 31 | extend type Mutation { 32 | sendTransaction( 33 | abi: String, 34 | name: String, 35 | address: String, 36 | fn: String, 37 | params: TransactionParams, 38 | gasLimit: String, 39 | gasPrice: String, 40 | value: String, 41 | scaleGasEstimate: String, 42 | minimumGas: String 43 | ): Transaction 44 | } 45 | `; -------------------------------------------------------------------------------- /src/types/Block.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | export interface Block extends ethers.providers.Block { 4 | __typename: string 5 | id: number 6 | } -------------------------------------------------------------------------------- /src/types/EventFilter.ts: -------------------------------------------------------------------------------- 1 | import { EventTopics } from "./EventTopics"; 2 | 3 | export interface EventFilter { 4 | abi?: string, 5 | address?: string, 6 | name?: string, 7 | contract?: string, 8 | event?: string, 9 | params?: Array, 10 | fromBlock?: string | number, 11 | toBlock?: string | number, 12 | topics?: EventTopics, 13 | extraTopics?: EventTopics 14 | } -------------------------------------------------------------------------------- /src/types/EventTopics.ts: -------------------------------------------------------------------------------- 1 | export interface EventTopics { 2 | types: Array 3 | values: Array 4 | } 5 | -------------------------------------------------------------------------------- /src/types/LogEvent.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | 3 | export interface LogEvent { 4 | log: ethers.providers.Log 5 | event: ethers.utils.LogDescription 6 | } 7 | -------------------------------------------------------------------------------- /src/types/ProviderSource.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | 3 | export type ProviderSource = () => Promise 4 | -------------------------------------------------------------------------------- /src/types/Transaction.ts: -------------------------------------------------------------------------------- 1 | export class TransactionParams { 2 | public __typename = 'JSON' 3 | 4 | constructor ( 5 | public values: Array 6 | ) {} 7 | } 8 | 9 | export class Transaction { 10 | public __typename = 'Transaction' 11 | public id: number 12 | public fn: any = null 13 | public name: string = null 14 | public abi: string = null 15 | public address: string = null 16 | public completed: boolean = false 17 | public sent: boolean = false 18 | public hash: string = null 19 | public error: string = null 20 | public gasLimit: string = null 21 | public gasPrice: string = null 22 | public scaleGasEstimate: string = null 23 | public minimumGas: string = null 24 | public blockNumber: number = null 25 | public params: TransactionParams = new TransactionParams([]) 26 | public value: string = null 27 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Block' 2 | export * from './EventFilter' 3 | export * from './EventTopics' 4 | export * from './LogEvent' 5 | export * from './ProviderSource' 6 | export * from './Transaction' -------------------------------------------------------------------------------- /src/utils/__mocks__/castToJsonRpcProvider.ts: -------------------------------------------------------------------------------- 1 | export function castToJsonRpcProvider(provider: any): any { 2 | return provider 3 | } -------------------------------------------------------------------------------- /src/utils/__tests__/castToJsonRpcProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { castToJsonRpcProvider } from '../castToJsonRpcProvider' 2 | 3 | describe('castToJsonRpcProvider', () => { 4 | it('should handle null or undefined', () => { 5 | expect(() => castToJsonRpcProvider(null)).toThrow(/requires JsonRpcProvider/) 6 | expect(() => castToJsonRpcProvider(undefined)).toThrow(/requires JsonRpcProvider/) 7 | }) 8 | 9 | it('should fail with an object that doesnt have getSigner', () => { 10 | // @ts-ignore 11 | expect(() => castToJsonRpcProvider('test')).toThrow(/requires JsonRpcProvider/) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/utils/castToJsonRpcProvider.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | export function castToJsonRpcProvider(provider: ethers.providers.BaseProvider): ethers.providers.JsonRpcProvider { 4 | if (!provider || !provider['getSigner']) { 5 | throw new Error('requires JsonRpcProvider') 6 | } 7 | // @ts-ignore 8 | return provider 9 | } -------------------------------------------------------------------------------- /src/utils/encodeEventTopics.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | import { EventTopics } from '../types/EventTopics' 3 | 4 | export function encodeEventTopics(extraTopics: EventTopics): Array { 5 | let encodedEventTopics = [] 6 | 7 | if (extraTopics.types.length !== extraTopics.values.length) { 8 | throw new Error(`extraTopics must have same number of types and values`) 9 | } 10 | 11 | for (let extraTopicIndex = 0; extraTopicIndex < extraTopics.types.length; extraTopicIndex++) { 12 | const type = extraTopics.types[extraTopicIndex] 13 | const value = extraTopics.values[extraTopicIndex] 14 | let encodedValue = null 15 | if (value) { 16 | encodedValue = ethers.utils.defaultAbiCoder.encode([type], [value]) 17 | } 18 | encodedEventTopics.push(encodedValue) 19 | } 20 | 21 | return encodedEventTopics 22 | } -------------------------------------------------------------------------------- /src/utils/gasCalculator.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers" 2 | 3 | export function gasCalculator( 4 | gasLimit: ethers.utils.BigNumberish, 5 | estimatedGasLimit: ethers.utils.BigNumberish, 6 | scaleGasEstimate: ethers.utils.BigNumberish, 7 | minimumGas: ethers.utils.BigNumberish 8 | ): ethers.utils.BigNumber { 9 | let result: ethers.utils.BigNumber 10 | 11 | if (gasLimit) { 12 | result = ethers.utils.bigNumberify(gasLimit) 13 | } else if (estimatedGasLimit) { 14 | result = ethers.utils.bigNumberify(estimatedGasLimit) 15 | if (scaleGasEstimate) { 16 | result = ethers.utils.bigNumberify(scaleGasEstimate).mul(result) 17 | } 18 | } 19 | 20 | if (minimumGas && result < minimumGas) { 21 | result = ethers.utils.bigNumberify(minimumGas) 22 | } 23 | 24 | return result 25 | } 26 | 27 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './gasCalculator' -------------------------------------------------------------------------------- /src/utils/normalizeAddress.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | export function normalizeAddress( 4 | address: string 5 | ): string { 6 | try { 7 | if (address) { 8 | address = ethers.utils.getAddress(address) 9 | } 10 | } catch (e) { 11 | throw new Error(`Unable to normalize address: ${address}: ${e.message}`) 12 | } 13 | 14 | return address 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "outDir": "lib" 5 | }, 6 | "external": [ 7 | "ethers" 8 | ], 9 | "include": ["src/**/*.ts"], 10 | "exclude": ["src/__tests__/**.ts", "src/__mocks__/**.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode": "modules", 3 | "out": "docs", 4 | "exclude" : [ 5 | "node_modules", 6 | "**/index.ts", 7 | "**/__tests__/**", 8 | "**/__mocks__/**" 9 | ], 10 | "excludeNotExported": "true", 11 | "excludeExternals": "false", 12 | "ignoreCompilerErrors": "true", 13 | "experimentalDecorators": "true", 14 | "target": "ES6" 15 | } --------------------------------------------------------------------------------