Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Follow Proxies #41

Merged
merged 9 commits into from
Nov 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/violet-cobras-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@dethcrypto/eth-sdk': minor
---

Given an address to a proxy, eth-sdk now generates ethers Contract for implementation contract

As we need to call the chain to get the implementation contract address, two new config options are introduced. You can
specify Ethereum JSON-RPC endpoints in `config.rpc` and opt out from proxy following with `config.noFollowProxies`.
30 changes: 29 additions & 1 deletion packages/eth-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
- [`outputPath`](#outputpath)
- [`etherscanKey`](#etherscankey)
- [`etherscanURLs`](#etherscanurls)
- [`rpc`](#rpc)
- [`noFollowProxies`](#nofollowproxies)
- [Examples](#examples)
- [Videos](#videos)
- [Videos](#videos)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a Markdown All in One extension for VSCode which updates the TOC automatically.

- [Motivation and use cases](#motivation-and-use-cases)
- [Contributing](#contributing)
- [License](#license)
Expand Down Expand Up @@ -207,6 +209,32 @@ Key-value pairs of network identifier and Etherscan API URL to fetch ABIs from.
}
```

### `rpc`

Configuration for Ethereum JSON-RPC provider needed for _following proxies_.

```json
{
"rpc": {
"mainnet": "https://mainnet.infura.io/v3/00000000000000000000000000000000",
"kovan": "https://kovan.infura.io/v3/00000000000000000000000000000000"
}
}
```

For every contract address, eth-sdk checks if it's a proxy, and if it is, it saves the ABI of the implementation
contract instead of the ABI of the proxy.

### `noFollowProxies`

You can opt out of proxy following by setting `noFollowProxies` flag in your config to `true`.

```json
{
"noFollowProxies": true
}
```

# Examples

Check out examples of using `eth-sdk` in [`/examples`][examples] directory.
Expand Down
5 changes: 4 additions & 1 deletion packages/eth-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"zod": "^3.11.5"
},
"peerDependencies": {
"ethers": "^5.3.1"
"ethers": "^5",
"@ethersproject/abstract-provider": "^5",
"@ethersproject/abi": "^5",
"@ethersproject/bignumber": "^5"
}
}
106 changes: 106 additions & 0 deletions packages/eth-sdk/src/abi-management/detectProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Interface } from '@ethersproject/abi'
import { TransactionRequest } from '@ethersproject/abstract-provider'
import { expect, mockFn } from 'earljs'
import { constants } from 'ethers'

import { randomAddress } from '../../test/test-utils'
import { Abi } from '../types'
import { detectProxy, EIP1967_IMPLEMENTATION_STORAGE_SLOT } from './detectProxy'
import type { RpcProvider } from './getRpcProvider'

describe(detectProxy.name, () => {
const abiWithImplementationGetter: Abi = [
{ name: 'implementation', type: 'function', outputs: [{ type: 'address' }], stateMutability: 'view' },
]
const proxyAddr = randomAddress('0x123')
const implAddr = randomAddress('0x456')
const implementationCall: TransactionRequest = {
to: proxyAddr,
data: new Interface(abiWithImplementationGetter).encodeFunctionData('implementation', []),
}

it('detects .implementation getter', async () => {
const rpcProvider = {
call: mockFn<RpcProvider['call']>().resolvesToOnce(implAddr),
getCode: mockFn<RpcProvider['getCode']>().resolvesToOnce('0xfff'),
getStorageAt: mockFn<RpcProvider['getStorageAt']>(),
}

const actual = await detectProxy(proxyAddr, abiWithImplementationGetter, rpcProvider)

expect(actual).toEqual({
implAddress: implAddr,
isProxy: true,
})
expect(rpcProvider.call).toHaveBeenCalledWith([implementationCall])
expect(rpcProvider.getCode).toHaveBeenCalledWith([implAddr])
})

it('returns { isProxy: false } when .implementation returns nothing', async () => {
const rpcProvider = {
call: mockFn<RpcProvider['call']>().resolvesToOnce(constants.AddressZero),
getCode: mockFn<RpcProvider['getCode']>().resolvesToOnce('0xfff'),
getStorageAt: mockFn<RpcProvider['getStorageAt']>(),
}

const actual = await detectProxy(proxyAddr, abiWithImplementationGetter, rpcProvider)

expect(rpcProvider.getCode.calls.length).toEqual(0)
expect(rpcProvider.getStorageAt.calls.length).toEqual(0)
expect(actual).toEqual({ isProxy: false })
})

it('returns { isProxy: false } when address from .implementation has no contract code', async () => {
const rpcProvider = {
call: mockFn<RpcProvider['call']>().resolvesToOnce(implAddr),
getCode: mockFn<RpcProvider['getCode']>().given(implAddr).resolvesToOnce(constants.AddressZero),
getStorageAt: mockFn<RpcProvider['getStorageAt']>(),
}

const actual = await detectProxy(proxyAddr, abiWithImplementationGetter, rpcProvider)

expect(rpcProvider.getStorageAt.calls.length).toEqual(0)
expect(actual).toEqual({ isProxy: false })
})

it('reads storage under EIP1967 implementation storage slot', async () => {
const rpcProvider = {
call: mockFn<RpcProvider['call']>(),
getCode: mockFn<RpcProvider['getCode']>().resolvesToOnce('0xfff'),
getStorageAt: mockFn<RpcProvider['getStorageAt']>()
.given(proxyAddr, EIP1967_IMPLEMENTATION_STORAGE_SLOT)
.resolvesToOnce(implAddr),
}
const abi: Abi = []

const actual = await detectProxy(proxyAddr, abi, rpcProvider)

expect(actual).toEqual({ implAddress: implAddr, isProxy: true })
expect(rpcProvider.getStorageAt).toHaveBeenCalledWith([proxyAddr, EIP1967_IMPLEMENTATION_STORAGE_SLOT])
})

it('detects and calls custom implementation getters', async () => {
const abi: Abi = [
{ name: 'currentImplementation', type: 'function', outputs: [{ type: 'address' }], stateMutability: 'view' },
{
name: 'pendingCurrentImplementation',
type: 'function',
outputs: [{ type: 'address' }],
stateMutability: 'view',
},
]
const rpcProvider = {
call: mockFn<RpcProvider['call']>()
.given({ to: proxyAddr, data: new Interface(abi).encodeFunctionData('currentImplementation', []) })
.resolvesToOnce(implAddr),
getCode: mockFn<RpcProvider['getCode']>().resolvesToOnce('0xfff'),
getStorageAt: mockFn<RpcProvider['getStorageAt']>().resolvesToOnce('0x0'),
}

const actual = await detectProxy(proxyAddr, abi, rpcProvider)
expect(actual).toEqual({
implAddress: implAddr,
isProxy: true,
})
})
})
71 changes: 71 additions & 0 deletions packages/eth-sdk/src/abi-management/detectProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Interface } from '@ethersproject/abi'
import { BigNumber } from '@ethersproject/bignumber'

import { Address, parseAddress } from '../config'
import { Abi, JsonFragment } from '../types'
import { RpcProvider } from './getRpcProvider'

export async function detectProxy(address: Address, abi: Abi, provider: RpcProvider): Promise<DetectProxyResult> {
const stored = await lookForImplementationAddr(address, abi, provider)

const asNumber = BigNumber.from(stored || 0)
if (!asNumber.isZero()) {
const implAddress = asNumber.toHexString()
const code = await provider.getCode(implAddress)
const isContract = !BigNumber.from(code).isZero()

if (isContract) {
return { implAddress: parseAddress(implAddress), isProxy: true }
}
}

return { isProxy: false }
}

export type DetectProxyResult = { implAddress: Address; isProxy: true } | { isProxy: false }

async function lookForImplementationAddr(address: Address, abi: Abi, provider: RpcProvider): Promise<BigNumber | null> {
const call = async (name: string) =>
BigNumber.from(
await provider.call({
to: address,
data: new Interface(abi).encodeFunctionData(name, []),
}),
)

// If there is an `.implementation` getter, we try to call it.
const implementationGetter = abi.find((fragment) => fragment.name === 'implementation')
if (implementationGetter && isPossibleImplementationGetter(implementationGetter)) {
return call('implementation')
}

// We check storage slot specified by EIP-1967 to hold implementation address.
// see https://eips.ethereum.org/EIPS/eip-1967
const stored = BigNumber.from(await provider.getStorageAt(address, EIP1967_IMPLEMENTATION_STORAGE_SLOT))
if (!stored.isZero()) return stored

// Otherwise, we try shortest getter ending with "Implementation"
const possibleImplementationGetters = abi.filter(isPossibleImplementationGetter)
if (possibleImplementationGetters.length) {
const [frag] = possibleImplementationGetters.sort((a, b) => a.name.length - b.name.length)
return call(frag.name)
}

return null
}

/** @internal */
export const EIP1967_IMPLEMENTATION_STORAGE_SLOT = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'

const isPossibleImplementationGetter = (frag: JsonFragment): frag is JsonFragment & { name: string } => {
if (
frag.type === 'function' &&
frag.name &&
frag.name.match(/[iI]mplementation$/) &&
(frag.stateMutability === 'view' || frag.stateMutability === 'pure')
) {
const output = frag.outputs?.[0]
return output?.type === 'address'
}
return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, mockFn } from 'earljs'
import { constants } from 'ethers'

import { parseAddress, UserEtherscanURLs } from '../../config'
import { Abi } from '../../types'
import { UserProvidedNetworkSymbol } from '../networks'
import { FetchAbi, getABIFromEtherscan } from './getAbiFromEtherscan'

Expand Down Expand Up @@ -39,7 +40,7 @@ describe(getABIFromEtherscan.name, () => {
const ADDRESS_ZERO = parseAddress(constants.AddressZero)
const DAI_ADDRESS = parseAddress('0x6B175474E89094C44Da98b954EedeAC495271d0F')

const RETURNED_ABI = ['{{ RETURNED_ABI }}']
const RETURNED_ABI = ['{{ RETURNED_ABI }}'] as Abi
function mockEndpoint() {
const fetch: FetchAbi = async (_url) => ({
body: JSON.stringify({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import got, { Response } from 'got'

import type { Address } from '../../config'
import type { Abi } from '../../types'
import type { URLString } from '../../utils/utility-types'
import { NetworkSymbol, symbolToNetworkId, UserProvidedNetworkSymbol } from '../networks'
import { networkIDtoEndpoints, UserEtherscanURLs } from './urls'
import { networkToEtherscanUrl, UserEtherscanURLs } from './urls'

export async function getABIFromEtherscan(
networkSymbol: NetworkSymbol,
address: Address,
apiKey: string,
userNetworks: UserEtherscanURLs,
fetch: FetchAbi = got,
): Promise<object> {
): Promise<Abi> {
const apiUrl = getEtherscanLinkFromNetworkSymbol(networkSymbol, userNetworks)
if (!apiUrl) {
throw new Error(`Can't find network info for ${networkSymbol}`)
Expand All @@ -26,7 +27,7 @@ export async function getABIFromEtherscan(
throw new Error(`Can't find mainnet abi for ${address}. Msg: ${rawResponse.body}`)
}

const abi = JSON.parse(jsonResponse.result)
const abi = JSON.parse(jsonResponse.result) as Abi

return abi
}
Expand All @@ -44,7 +45,7 @@ function getEtherscanLinkFromNetworkSymbol(

const networkId = symbolToNetworkId[networkSymbol]

return networkId && networkIDtoEndpoints[networkId]
return networkId && networkToEtherscanUrl[networkId]
}

function isUserProvidedNetwork(
Expand Down
2 changes: 1 addition & 1 deletion packages/eth-sdk/src/abi-management/etherscan/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type NetworkId2Etherscan = { [networkID in NetworkID]: URLString }
*
* @see https://github.com/nomiclabs/hardhat/blob/master/packages/hardhat-etherscan/src/network/prober.ts
*/
export const networkIDtoEndpoints: NetworkId2Etherscan = {
export const networkToEtherscanUrl: NetworkId2Etherscan = {
[NetworkID.MAINNET]: 'https://api.etherscan.io/api',
[NetworkID.ROPSTEN]: 'https://api-ropsten.etherscan.io/api',
[NetworkID.RINKEBY]: 'https://api-rinkeby.etherscan.io/api',
Expand Down
38 changes: 38 additions & 0 deletions packages/eth-sdk/src/abi-management/getRpcProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect } from 'earljs'
import { providers } from 'ethers'

import { EthSdkConfig } from '../config'
import { createEthSdkConfig } from '../config'
import { getRpcProvider } from './getRpcProvider'
import { UserProvidedNetworkSymbol } from './networks'

describe(getRpcProvider.name, () => {
const config = createEthSdkConfig({ contracts: {} })

it('defaults to a built-in URL', () => {
const provider = getRpcProvider(config, 'mainnet') as providers.JsonRpcProvider

expect(provider.connection.url).toEqual('https://mainnet.infura.io/v3/0993a4f4500c4fff88649d28b331898c')
})

it('uses the RPC url from config', () => {
const cfg: EthSdkConfig = {
...config,
rpc: { kovan: 'https://kovan.test', polygonMumbai: 'https://polygonMumbai.test' },
}

let provider = getRpcProvider(cfg, 'kovan') as providers.JsonRpcProvider

expect(provider.connection.url).toEqual('https://kovan.test')

provider = getRpcProvider(cfg, 'polygonMumbai') as providers.JsonRpcProvider

expect(provider.connection.url).toEqual('https://polygonMumbai.test')
})

it('returns null when there is no RPC URL for given network', () => {
const provider = getRpcProvider(config, 'user-provided' as UserProvidedNetworkSymbol)

expect(provider).toEqual(null)
})
})
24 changes: 24 additions & 0 deletions packages/eth-sdk/src/abi-management/getRpcProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ethers } from 'ethers'

import { EthSdkConfig, RpcURLs } from '../config'
import { NetworkSymbol } from './networks'

const INFURA_PROJECT_URL = '0993a4f4500c4fff88649d28b331898c'

const rpcProviders: RpcURLs = {
mainnet: `https://mainnet.infura.io/v3/${INFURA_PROJECT_URL}`,
kovan: `https://kovan.infura.io/v3/${INFURA_PROJECT_URL}`,
rinkeby: `https://rinkeby.infura.io/v3/${INFURA_PROJECT_URL}`,
ropsten: `https://ropsten.infura.io/v3/${INFURA_PROJECT_URL}`,
goerli: `https://goerli.infura.io/v3/${INFURA_PROJECT_URL}`,
}

export function getRpcProvider(config: EthSdkConfig, network: NetworkSymbol): RpcProvider | null {
const rpcUrl = config.rpc[network] || rpcProviders[network]

return rpcUrl ? new ethers.providers.JsonRpcProvider(rpcUrl) : null
}

export type RpcProvider = Pick<ethers.providers.Provider, 'getCode' | 'getStorageAt' | 'call'>

export type GetRpcProvider = typeof getRpcProvider
Loading