wormhole/clients/js/evm.ts

580 lines
22 KiB
TypeScript

import { ethers } from "ethers"
import { NETWORKS } from "./networks"
import { encode, Encoding, impossible, Payload, typeWidth } from "./vaa"
import axios from "axios";
import * as celo from "@celo-tools/celo-ethers-wrapper";
import { solidityKeccak256 } from "ethers/lib/utils"
import { CHAINS, CONTRACTS, Contracts, EVMChainName } from "@certusone/wormhole-sdk/lib/cjs/utils/consts";
import { BridgeImplementation__factory, Implementation__factory, NFTBridgeImplementation__factory } from "@certusone/wormhole-sdk/lib/cjs/ethers-contracts";
const _IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
export async function query_contract_evm(
network: "MAINNET" | "TESTNET" | "DEVNET",
chain: EVMChainName,
module: "Core" | "NFTBridge" | "TokenBridge",
contract_address: string | undefined,
_rpc: string | undefined
): Promise<object> {
let n = NETWORKS[network][chain]
let rpc: string | undefined = _rpc ?? n.rpc;
if (rpc === undefined) {
throw Error(`No ${network} rpc defined for ${chain} (see networks.ts)`)
}
let contracts: Contracts = CONTRACTS[network][chain]
const provider = new ethers.providers.JsonRpcProvider(rpc)
let result: any = {}
switch (module) {
case "Core":
contract_address = contract_address ? contract_address : contracts.core;
if (contract_address === undefined) {
throw Error(`Unknown core contract on ${network} for ${chain}`)
}
const core = Implementation__factory.connect(contract_address, provider)
result.address = contract_address
result.currentGuardianSetIndex = await core.getCurrentGuardianSetIndex()
let guardianSetsPromise = Promise.all([...Array(result.currentGuardianSetIndex + 1).keys()].map((i) => core.getGuardianSet(i)))
let [
guardianSetExpiry,
chainId,
evmChainId,
isFork,
governanceChainId,
governanceContract,
messageFee,
implementationSlot,
guardianSets
] = await Promise.all([
core.getGuardianSetExpiry(),
core.chainId(),
maybeUnsupported(core.evmChainId()),
maybeUnsupported(core.isFork()),
core.governanceChainId(),
core.governanceContract(),
core.messageFee(),
getStorageAt(rpc, contract_address, _IMPLEMENTATION_SLOT, ["address"]),
guardianSetsPromise
])
result.guardianSetExpiry = guardianSetExpiry
result.chainId = chainId
result.evmChainId = evmChainId.toString()
result.isFork = isFork
result.governanceChainId = governanceChainId
result.governanceContract = governanceContract
result.messageFee = messageFee
result.implementation = implementationSlot[0]
result.isInitialized = await core.isInitialized(result.implementation)
result.guardianSet = {}
for (let [i, guardianSet] of guardianSets.entries()) {
result.guardianSet[i] = { keys: guardianSet[0], expiry: guardianSet[1] }
}
break
case "TokenBridge":
contract_address = contract_address ? contract_address : contracts.token_bridge;
if (contract_address === undefined) {
throw Error(`Unknown token bridge contract on ${network} for ${chain}`)
}
const tb = BridgeImplementation__factory.connect(contract_address, provider)
result.address = contract_address
const registrationsPromise = Promise.all(
Object.entries(CHAINS)
.filter(([c_name, _]) => c_name !== chain && c_name !== "unset")
.map(async ([c_name, c_id]) => [c_name, await tb.bridgeContracts(c_id)])
)
let [
wormhole,
implementationSlotTb,
tokenImplementation,
chainIdTb,
finality,
evmChainIdTb,
isForkTb,
governanceChainIdTb,
governanceContractTb,
WETH,
registrations
] = await Promise.all([
tb.wormhole(),
getStorageAt(rpc, contract_address, _IMPLEMENTATION_SLOT, ["address"]),
tb.tokenImplementation(),
tb.chainId(),
tb.finality(),
maybeUnsupported(tb.evmChainId()),
maybeUnsupported(tb.isFork()),
tb.governanceChainId(),
tb.governanceContract(),
tb.WETH(),
registrationsPromise
])
result.wormhole = wormhole
result.implementation = implementationSlotTb[0]
result.isInitialized = await tb.isInitialized(result.implementation)
result.tokenImplementation = tokenImplementation
result.chainId = chainIdTb
result.finality = finality
result.evmChainId = evmChainIdTb.toString()
result.isFork = isForkTb
result.governanceChainId = governanceChainIdTb
result.governanceContract = governanceContractTb
result.WETH = WETH
result.registrations = {}
for (let [c_name, c] of registrations) {
result.registrations[c_name] = c
}
break
case "NFTBridge":
contract_address = contract_address ? contract_address : contracts.nft_bridge;
if (contract_address === undefined) {
throw Error(`Unknown nft bridge contract on ${network} for ${chain}`)
}
const nb = NFTBridgeImplementation__factory.connect(contract_address, provider)
result.address = contract_address
const registrationsPromiseNb = Promise.all(
Object.entries(CHAINS)
.filter(([c_name, _]) => c_name !== chain && c_name !== "unset")
.map(async ([c_name, c_id]) => [c_name, await nb.bridgeContracts(c_id)])
)
let [
wormholeNb,
implementationSlotNb,
tokenImplementationNb,
chainIdNb,
finalityNb,
evmChainIdNb,
isForkNb,
governanceChainIdNb,
governanceContractNb,
registrationsNb
] = await Promise.all([
nb.wormhole(),
getStorageAt(rpc, contract_address, _IMPLEMENTATION_SLOT, ["address"]),
nb.tokenImplementation(),
nb.chainId(),
nb.finality(),
maybeUnsupported(nb.evmChainId()),
maybeUnsupported(nb.isFork()),
nb.governanceChainId(),
nb.governanceContract(),
registrationsPromiseNb
])
result.wormhole = wormholeNb
result.implementation = implementationSlotNb[0]
result.isInitialized = await nb.isInitialized(result.implementation)
result.tokenImplementation = tokenImplementationNb
result.chainId = chainIdNb
result.finality = finalityNb
result.evmChainId = evmChainIdNb.toString()
result.isFork = isForkNb
result.governanceChainId = governanceChainIdNb
result.governanceContract = governanceContractNb
result.registrations = {}
for (let [c_name, c] of registrationsNb) {
result.registrations[c_name] = c
}
break
default:
impossible(module)
}
return result
}
export async function getImplementation(
network: "MAINNET" | "TESTNET" | "DEVNET",
chain: EVMChainName,
module: "Core" | "NFTBridge" | "TokenBridge",
contract_address: string | undefined,
_rpc: string | undefined
): Promise<ethers.BigNumber> {
let n = NETWORKS[network][chain]
let rpc: string | undefined = _rpc ?? n.rpc;
if (rpc === undefined) {
throw Error(`No ${network} rpc defined for ${chain} (see networks.ts)`)
}
let contracts: Contracts = CONTRACTS[network][chain]
switch (module) {
case "Core":
contract_address = contract_address ? contract_address : contracts.core;
break
case "TokenBridge":
contract_address = contract_address ? contract_address : contracts.token_bridge;
break
case "NFTBridge":
contract_address = contract_address ? contract_address : contracts.nft_bridge;
break
default:
impossible(module)
}
return (await getStorageAt(rpc, contract_address, _IMPLEMENTATION_SLOT, ["address"]))[0]
}
export async function execute_evm(
payload: Payload,
vaa: Buffer,
network: "MAINNET" | "TESTNET" | "DEVNET",
chain: EVMChainName,
contract_address: string | undefined,
_rpc: string | undefined
) {
let n = NETWORKS[network][chain]
let rpc: string | undefined = _rpc ?? n.rpc;
if (rpc === undefined) {
throw Error(`No ${network} rpc defined for ${chain} (see networks.ts)`)
}
if (!n.key) {
throw Error(`No ${network} key defined for ${chain} (see networks.ts)`)
}
let key: string = n.key
let contracts: Contracts = CONTRACTS[network][chain]
let provider: ethers.providers.JsonRpcProvider;
let signer: ethers.Wallet;
if (chain === "celo") {
provider = new celo.CeloProvider(rpc)
await provider.ready
signer = new celo.CeloWallet(key, provider)
} else {
provider = new ethers.providers.JsonRpcProvider(rpc)
signer = new ethers.Wallet(key, provider)
}
// Here we apply a set of chain-specific overrides.
// NOTE: some of these might have only been tested on mainnet. If it fails in
// testnet (or devnet), they might require additional guards
let overrides: ethers.Overrides = {}
if (chain === "karura" || chain == "acala") {
overrides = await getKaruraGasParams(n.rpc)
} else if (chain === "polygon") {
let feeData = await provider.getFeeData();
overrides = {
maxFeePerGas: feeData.maxFeePerGas?.mul(50) || undefined,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas?.mul(50) || undefined,
};
} else if (chain === "klaytn" || chain === "fantom") {
overrides = { gasPrice: (await signer.getGasPrice()).toString() }
}
switch (payload.module) {
case "Core":
contract_address = contract_address ? contract_address : contracts.core;
if (contract_address === undefined) {
throw Error(`Unknown core contract on ${network} for ${chain}`)
}
let c = new Implementation__factory(signer)
let cb = c.attach(contract_address)
switch (payload.type) {
case "GuardianSetUpgrade":
console.log("Submitting new guardian set")
console.log("Hash: " + (await cb.submitNewGuardianSet(vaa, overrides)).hash)
break
case "ContractUpgrade":
console.log("Upgrading core contract")
console.log("Hash: " + (await cb.submitContractUpgrade(vaa, overrides)).hash)
break
default:
impossible(payload)
}
break
case "NFTBridge":
contract_address = contract_address ? contract_address : contracts.nft_bridge;
if (contract_address === undefined) {
throw Error(`Unknown nft bridge contract on ${network} for ${chain}`)
}
let n = new NFTBridgeImplementation__factory(signer)
let nb = n.attach(contract_address)
switch (payload.type) {
case "ContractUpgrade":
console.log("Upgrading contract")
console.log("Hash: " + (await nb.upgrade(vaa, overrides)).hash)
console.log("Don't forget to verify the new implementation! See ethereum/VERIFY.md for instructions")
break
case "RegisterChain":
console.log("Registering chain")
console.log("Hash: " + (await nb.registerChain(vaa, overrides)).hash)
break
case "Transfer":
console.log("Completing transfer")
console.log("Hash: " + (await nb.completeTransfer(vaa, overrides)).hash)
break
default:
impossible(payload)
}
break
case "TokenBridge":
contract_address = contract_address ? contract_address : contracts.token_bridge;
if (contract_address === undefined) {
throw Error(`Unknown token bridge contract on ${network} for ${chain}`)
}
let t = new BridgeImplementation__factory(signer)
let tb = t.attach(contract_address)
switch (payload.type) {
case "ContractUpgrade":
console.log("Upgrading contract")
console.log("Hash: " + (await tb.upgrade(vaa, overrides)).hash)
console.log("Don't forget to verify the new implementation! See ethereum/VERIFY.md for instructions")
break
case "RegisterChain":
console.log("Registering chain")
console.log("Hash: " + (await tb.registerChain(vaa, overrides)).hash)
break
case "Transfer":
console.log("Completing transfer")
console.log("Hash: " + (await tb.completeTransfer(vaa, overrides)).hash)
break
case "AttestMeta":
console.log("Creating wrapped token")
console.log("Hash: " + (await tb.createWrapped(vaa, overrides)).hash)
break
case "TransferWithPayload":
console.log("Completing transfer with payload")
console.log("Hash: " + (await tb.completeTransferWithPayload(vaa, overrides)).hash)
break
default:
impossible(payload)
break
}
break
default:
impossible(payload)
}
}
/**
*
* Hijack a core contract. This function is useful when working with a mainnet
* fork (hardhat or anvil). A fork of the mainnet contract will naturally store
* the mainnet guardian set, so we can't readily interact with these contracts,
* because we can't forge signed VAAs for those guardians. This function uses
* [[setStorageAt]] to override the guardian set to something we have the
* private keys for (typically the devnet guardian used for testing).
* This way we can test contract upgrades before rolling them out on mainnet.
*
* @param rpc the JSON RPC endpoint (needs to be hardhat of anvil)
* @param contract_address address of the core bridge contract
* @param guardian_addresses addresses of the desired guardian set to upgrade to
* @param new_guardian_set_index if specified, the new guardian set will be
* written into this guardian set index, and the guardian set index of the
* contract changed to it.
* If unspecified, then the current guardian set index will be overridden.
* In particular, it's possible to both upgrade or downgrade the guardian set
* this way. The latter is useful for testing locally if you already have some
* VAAs handy that are signed by guardian set 0.
*/
export async function hijack_evm(
rpc: string,
contract_address: string,
guardian_addresses: string[],
new_guardian_set_index: number | undefined
): Promise<void> {
const GUARDIAN_SETS_SLOT = 0x02
const GUARDIAN_SET_INDEX_SLOT = 0x3
const provider = new ethers.providers.JsonRpcProvider(rpc)
const core = Implementation__factory.connect(contract_address, provider)
let guardianSetIndex: number
let guardianSetExpiry: number
[guardianSetIndex, guardianSetExpiry] = await getStorageAt(rpc, contract_address, GUARDIAN_SET_INDEX_SLOT, ["uint32", "uint32"])
console.log("Attempting to hijack core bridge guardian set.")
const current_set = await core.getGuardianSet(guardianSetIndex)
console.log(`Current guardian set (index ${guardianSetIndex}):`)
console.log(current_set[0])
if (new_guardian_set_index !== undefined) {
await setStorageAt(rpc, contract_address, GUARDIAN_SET_INDEX_SLOT, ["uint32", "uint32"], [new_guardian_set_index, guardianSetExpiry])
guardianSetIndex = await core.getCurrentGuardianSetIndex()
if (new_guardian_set_index !== guardianSetIndex) {
throw Error("Failed to update guardian set index.")
} else {
console.log(`Guardian set index updated to ${new_guardian_set_index}`)
}
}
const addresses_slot = computeMappingElemSlot(GUARDIAN_SETS_SLOT, guardianSetIndex)
console.log(`Writing new set of guardians into set ${guardianSetIndex}...`)
guardian_addresses.forEach(async (address, i) => {
await setStorageAt(rpc, contract_address, computeArrayElemSlot(addresses_slot, i), ["address"], [address])
})
await setStorageAt(rpc, contract_address, addresses_slot, ["uint256"], [guardian_addresses.length])
const after_guardian_set_index = await core.getCurrentGuardianSetIndex()
const new_set = await core.getGuardianSet(after_guardian_set_index)
console.log(`Current guardian set (index ${after_guardian_set_index}):`)
console.log(new_set[0])
console.log("Success.")
}
async function getKaruraGasParams(rpc: string): Promise<{
gasPrice: number;
gasLimit: number;
}> {
const gasLimit = 21000000;
const storageLimit = 64001;
const res = (
await axios.post(rpc, {
id: 0,
jsonrpc: "2.0",
method: "eth_getEthGas",
params: [
{
gasLimit,
storageLimit,
},
],
})
).data.result;
return {
gasLimit: parseInt(res.gasLimit, 16),
gasPrice: parseInt(res.gasPrice, 16),
};
}
////////////////////////////////////////////////////////////////////////////////
// Storage manipulation
//
// Below we define a set of utilities for working with the EVM storage. For
// reference on storage layout, see [1].
//
// [1]: https://docs.soliditylang.org/en/v0.8.14/internals/layout_in_storage.html
export type StorageSlot = ethers.BigNumber
// we're a little more permissive in contravariant positions...
export type StorageSlotish = ethers.BigNumberish
/**
*
* Compute the storage slot of an array element.
*
* @param array_slot the storage slot of the array variable
* @param offset the index of the element to compute the storage slot for
*/
export function computeArrayElemSlot(array_slot: StorageSlotish, offset: number): StorageSlot {
return ethers.BigNumber.from(solidityKeccak256(["bytes"], [array_slot])).add(offset)
}
/**
*
* Compute the storage slot of a mapping key.
*
* @param map_slot the storage slot of the mapping variable
* @param key the key to compute the storage slot for
*/
export function computeMappingElemSlot(map_slot: StorageSlotish, key: any): StorageSlot {
const slot_preimage = ethers.utils.defaultAbiCoder.encode(["uint256", "uint256"], [key, map_slot])
return ethers.BigNumber.from(solidityKeccak256(["bytes"], [slot_preimage]))
}
/**
*
* Get the values stored in a storage slot. [[ethers.Provider.getStorageAt]]
* returns the whole slot as one 32 byte value, but if there are multiple values
* stored in the slot (which solidity does to save gas), it is useful to parse
* the output accordingly. This function is a wrapper around the storage query
* provided by [[ethers]] that does the additional parsing.
*
* @param rpc the JSON RPC endpoint
* @param contract_address address of the contract to be queried
* @param storage_slot the storage slot to query
* @param types The types of values stored in the storage slot. It's a list,
* because solidity packs multiple values into a single storage slot to save gas
* when the elements fit.
*
* @returns _values the values to write into the slot (packed)
*/
async function getStorageAt(rpc: string, contract_address: string, storage_slot: StorageSlotish, types: Encoding[]): Promise<any[]> {
const total = types.map((typ) => typeWidth(typ)).reduce((x, y) => (x + y))
if (total > 32) {
throw new Error(`Storage slots can contain a maximum of 32 bytes. Total size of ${types} is ${total} bytes.`)
}
const string_val: string =
await (new ethers.providers.JsonRpcProvider(rpc).getStorageAt(contract_address, storage_slot))
let val = ethers.BigNumber.from(string_val)
let ret: any[] = []
// we decode the elements one by one, by shifting down the stuff we've parsed already
types.forEach((typ) => {
const padded = ethers.utils.defaultAbiCoder.encode(["uint256"], [val])
ret.push(ethers.utils.defaultAbiCoder.decode([typ], padded)[0])
val = val.shr(typeWidth(typ) * 8)
})
return ret
}
/**
*
* Use the 'hardhat_setStorageAt' rpc method to override a storage slot of a
* contract. This method is understood by both hardhat and anvil (from foundry).
* Useful for manipulating the storage of a forked mainnet contract (such as for
* changing the guardian set to allow submitting VAAs to).
*
* @param rpc the JSON RPC endpoint (needs to be hardhat of anvil)
* @param contract_address address of the contract to be queried
* @param storage_slot the storage slot to query
* @param types The types of values stored in the storage slot. It's a list,
* because solidity packs multiple values into a single storage slot to save gas
* when the elements fit. This means that when writing into the slot, all values
* must be accounted for, otherwise we end up zeroing out some fields.
* @param values the values to write into the slot (packed)
*
* @returns the `data` property of the JSON response
*/
export async function setStorageAt(rpc: string, contract_address: string, storage_slot: StorageSlotish, types: Encoding[], values: any[]): Promise<any> {
// we need to reverse the values and types arrays, because the first element
// is stored at the rightmost bytes.
//
// for example:
// uint32 a
// uint32 b
// will be stored as 0x...b...a
const _values = values.reverse()
const _types = types.reverse()
const total = _types.map((typ) => typeWidth(typ)).reduce((x, y) => (x + y))
// ensure that the types fit into a slot
if (total > 32) {
throw new Error(`Storage slots can contain a maximum of 32 bytes. Total size of ${_types} is ${total} bytes.`)
}
if (_types.length !== _values.length) {
throw new Error(`Expected ${_types.length} value(s), but got ${_values.length}.`)
}
// as far as I could tell, `ethers` doesn't provide a way to pack multiple
// values into a single slot (the abi coder pads everything to 32 bytes), so we do it ourselves
const val = "0x" + _types.map((typ, i) => encode(typ, _values[i])).reduce((x, y) => x + y).padStart(64, "0")
// format the storage slot
const slot = ethers.utils.defaultAbiCoder.encode(["uint256"], [storage_slot])
console.log(`slot ${slot} := ${val}`)
return (await axios.post(rpc, {
id: 0,
jsonrpc: "2.0",
method: "hardhat_setStorageAt",
params: [
contract_address,
slot,
val,
],
})).data
}
async function maybeUnsupported<T>(query: Promise<T>): Promise<T> {
try {
return await query
} catch (e) {
if (e.reason === "unsupported") {
return e.reason
}
throw e
}
}