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

feat: Script for interacting with SpokePools #854

Merged
merged 28 commits into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4b1bb8a
feat: Script for automating SpokePool deposits
pxrl Aug 7, 2023
94485f3
Merge branch 'master' into pxrl/depositScript
nicholaspai Aug 7, 2023
4f51071
Merge branch 'master' into pxrl/depositScript
pxrl Aug 7, 2023
df18bf9
Add yarn script target + tweak usage
pxrl Aug 7, 2023
fdd9ad4
Merge branch 'master' into pxrl/depositScript
nicholaspai Aug 7, 2023
b453813
Add basic support for dumping SpokePool config
pxrl Aug 9, 2023
7f05ce7
Merge remote-tracking branch 'origin/master' into pxrl/depositScript
pxrl Aug 10, 2023
ac72c69
Merge remote-tracking branch 'origin/master' into pxrl/depositScript
pxrl Aug 15, 2023
b566bcd
lint
pxrl Aug 15, 2023
bb4a867
fix: Gracefully handle token searches where token missing
pxrl Aug 16, 2023
c3c3e3f
Add "fetch" for dumping deposit and fill information
pxrl Aug 15, 2023
f50266c
Merge branch 'master' into pxrl/depositScript
pxrl Aug 16, 2023
dbfc262
lint
pxrl Aug 16, 2023
6be22a3
fix conflict
pxrl Aug 16, 2023
9c16a31
lint
pxrl Aug 16, 2023
bbfb153
Merge branch 'master' into pxrl/depositScript
pxrl Aug 21, 2023
b5dba90
Merge branch 'master' into pxrl/depositScript
pxrl Aug 21, 2023
e38356e
Merge branch 'master' into pxrl/depositScript
nicholaspai Aug 22, 2023
0a398f7
Merge branch 'master' into pxrl/depositScript
pxrl Aug 23, 2023
1554efb
Merge branch 'master' into pxrl/depositScript
pxrl Aug 28, 2023
94378ed
Tweak padding
pxrl Aug 22, 2023
731786d
improve: Incorporate James' feedback
pxrl Aug 24, 2023
8c1c29d
Use getSigner()
pxrl Aug 28, 2023
6d7e5ad
lint
pxrl Aug 28, 2023
ea24ac1
improve: Add input validation
pxrl Sep 4, 2023
caccd7b
improve: Overhaul print formatting
pxrl Sep 4, 2023
44badfc
Merge branch 'master' into pxrl/depositScript
pxrl Sep 4, 2023
081f860
lint + additional formatting improvements
pxrl Sep 4, 2023
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"clean": "dir=\"./node_modules\"; mv \"${dir}\" \"${dir}_\" 2>/dev/null && rm -r \"${dir}_\" &",
"reinstall": "yarn clean && yarn install && yarn build",
"update": "git pull && yarn reinstall && yarn version --non-interactive && git show --quiet",
"relay": "HARDHAT_CONFIG=./dist/hardhat.config.js node ./dist/index.js --relayer"
"relay": "HARDHAT_CONFIG=./dist/hardhat.config.js node ./dist/index.js --relayer",
"deposit": "yarn ts-node ./scripts/spokepool.ts deposit"
},
"devDependencies": {
"@nomiclabs/hardhat-ethers": "^2.2.3",
Expand Down
343 changes: 343 additions & 0 deletions scripts/spokepool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
import assert from "assert";
import * as contracts from "@across-protocol/contracts-v2";
import { LogDescription } from "@ethersproject/abi";
import { Contract, ethers, Wallet } from "ethers";
import minimist from "minimist";
import { groupBy } from "lodash";
import { config } from "dotenv";
import { getDeployedContract, getNetworkName, getNodeUrlList, getSigner, resolveTokenSymbols } from "../src/utils";

type ERC20 = {
address: string;
decimals: number;
symbol: string;
};

const { MaxUint256, Zero } = ethers.constants;
const { isAddress } = ethers.utils;

const testChains = [5, 280];
const chains = [1, 10, 137, 324, 8453, 42161];

function validateChainIds(chainIds: number[]): boolean {
const knownChainIds = [...chains, ...testChains];
return chainIds.every((chainId) => {
const ok = knownChainIds.includes(chainId);
if (!ok) {
console.log(`Invalid chain ID: ${chainId}`);
}
return ok;
});
}

function printDeposit(log: LogDescription): void {
const { originChainId, originToken } = log.args;
const eventArgs = Object.keys(log.args).filter((key) => isNaN(Number(key)));
const padLeft = eventArgs.reduce((acc, cur) => (cur.length > acc ? cur.length : acc), 0);

const fields = {
tokenSymbol: resolveTokenSymbols([originToken], originChainId)[0],
...Object.fromEntries(eventArgs.map((key) => [key, log.args[key]])),
};
console.log(
`Deposit # ${log.args.depositId} on ${getNetworkName(originChainId)}:\n` +
Object.entries(fields)
.map(([k, v]) => `\t${k.padEnd(padLeft)} : ${v}`)
.join("\n") +
"\n"
);
}

function printFill(log: LogDescription): void {
const { originChainId, destinationChainId, destinationToken, amount, totalFilledAmount } = log.args;
const eventArgs = Object.keys(log.args).filter((key) => isNaN(Number(key)));
const padLeft = eventArgs.reduce((acc, cur) => (cur.length > acc ? cur.length : acc), 0);

const fields = {
tokenSymbol: resolveTokenSymbols([destinationToken], destinationChainId)[0],
totalFilledPct: `${totalFilledAmount.mul(100).div(amount)} %`,
...Object.fromEntries(eventArgs.map((key) => [key, log.args[key]])),
};
console.log(
`Fill for ${getNetworkName(originChainId)} deposit # ${log.args.depositId}:\n` +
Object.entries(fields)
.map(([k, v]) => `\t${k.padEnd(padLeft)} : ${v}`)
.join("\n") +
"\n"
);
}

/**
* Resolves an ERC20 type from a chain ID, and symbol or address.
* @param token The address or symbol of the token to resolve.
* @param chainId The chain ID to resolve the token on.
* @returns The ERC20 attributes of the token.
*/
function resolveToken(token: string, chainId: number): ERC20 {
// `token` may be an address or a symbol. Normalise it to a symbol for easy lookup.
const symbol = !isAddress(token)
? token.toUpperCase()
: Object.values(contracts.TOKEN_SYMBOLS_MAP).find(({ addresses }) => addresses[chainId] === token)?.symbol;

const _token = contracts.TOKEN_SYMBOLS_MAP[symbol];
if (_token === undefined) {
throw new Error(`Token ${token} on chain ID ${chainId} unrecognised`);
}

return {
address: _token.addresses[chainId],
decimals: _token.decimals,
symbol: _token.symbol,
};
}

function resolveHubChainId(spokeChainId: number): number {
if (chains.includes(spokeChainId)) {
return 1;
}

assert(testChains.includes(spokeChainId), `Unsupported SpokePool chain ID: ${spokeChainId}`);
return 5;
}

async function getHubPoolContract(chainId: number): Promise<Contract> {
const contractName = "HubPool";
const hubPoolChainId = resolveHubChainId(chainId);

const hubPool = getDeployedContract(contractName, hubPoolChainId);
const provider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(hubPoolChainId, 1)[0]);
return hubPool.connect(provider);
}

async function getSpokePoolContract(chainId: number): Promise<Contract> {
const hubPool = await getHubPoolContract(chainId);
const spokePoolAddr = (await hubPool.crossChainContracts(chainId))[1];

const contract = new Contract(spokePoolAddr, contracts.SpokePool__factory.abi);
return contract;
pxrl marked this conversation as resolved.
Show resolved Hide resolved
}

async function deposit(args: Record<string, number | string>, signer: Wallet): Promise<boolean> {
const depositor = await signer.getAddress();
const [fromChainId, toChainId, baseAmount] = [Number(args.from), Number(args.to), Number(args.amount)];
const recipient = (args.recipient as string) ?? depositor;

if (!validateChainIds([fromChainId, toChainId])) {
usage(); // no return
}
const network = getNetworkName(fromChainId);

if (!isAddress(recipient)) {
console.log(`Invalid recipient address (${recipient})`);
usage(); // no return
}

const token = resolveToken(args.token as string, fromChainId);
const tokenSymbol = token.symbol.toUpperCase();
const amount = ethers.utils.parseUnits(baseAmount.toString(), args.decimals ? 0 : token.decimals);

const provider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(fromChainId, 1)[0]);
signer = signer.connect(provider);
const spokePool = (await getSpokePoolContract(fromChainId)).connect(signer);

const erc20 = new Contract(token.address, contracts.ExpandedERC20__factory.abi, signer);
pxrl marked this conversation as resolved.
Show resolved Hide resolved
const allowance = await erc20.allowance(depositor, spokePool.address);
if (amount.gt(allowance)) {
const approvalAmount = amount.mul(5);
const approval = await erc20.approve(spokePool.address, approvalAmount);
console.log(`Approving SpokePool for ${approvalAmount} ${tokenSymbol}: ${approval.hash}.`);
await approval.wait();
console.log("Approval complete...");
}

const relayerFeePct = Zero; // @todo: Make configurable.
const maxCount = MaxUint256;
const quoteTimestamp = Math.round(Date.now() / 1000);

const deposit = await spokePool.deposit(
recipient,
token.address,
amount,
toChainId,
relayerFeePct,
quoteTimestamp,
"0x",
maxCount
);
const { hash: transactionHash } = deposit;
console.log(`Submitting ${tokenSymbol} deposit on ${network}: ${transactionHash}.`);
const receipt = await deposit.wait();

receipt.logs
.filter((log) => log.address === spokePool.address)
.forEach((log) => printDeposit(spokePool.interface.parseLog(log)));

return true;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function dumpConfig(args: Record<string, number | string>, _signer: Wallet): Promise<boolean> {
const chainId = Number(args.chainId);
const _spokePool = await getSpokePoolContract(chainId);

const hubChainId = resolveHubChainId(chainId);
const spokeProvider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(chainId, 1)[0]);
const spokePool = _spokePool.connect(spokeProvider);

const [spokePoolChainId, hubPool, crossDomainAdmin, weth, _currentTime] = await Promise.all([
spokePool.chainId(),
spokePool.hubPool(),
spokePool.crossDomainAdmin(),
spokePool.wrappedNativeToken(),
spokePool.getCurrentTime(),
]);

if (chainId !== Number(spokePoolChainId)) {
throw new Error(`Chain ${chainId} SpokePool mismatch: ${spokePoolChainId} != ${chainId} (${spokePool.address})`);
}

const currentTime = `${_currentTime} (${new Date(Number(_currentTime) * 1000).toUTCString()})`;

const fields = {
hubChainId,
hubPool,
crossDomainAdmin,
weth,
currentTime,
};

// @todo: Support handlers for chain-specific configuration (i.e. address of bridge to L1).

const padLeft = Object.keys(fields).reduce((acc, cur) => (cur.length > acc ? cur.length : acc), 0);
console.log(
`${getNetworkName(chainId)} SpokePool configuration:\n` +
Object.entries(fields)
.map(([k, v]) => `\t${k.padEnd(padLeft)} : ${v}`)
.join("\n") +
"\n"
);

return true;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function fetchTxn(args: Record<string, number | string>, _signer: Wallet): Promise<boolean> {
pxrl marked this conversation as resolved.
Show resolved Hide resolved
const { txnHash } = args;
const chainId = Number(args.chainId);

if (!validateChainIds([chainId])) {
usage(); // no return
}

if (txnHash === undefined || typeof txnHash !== "string" || txnHash.length != 66 || !txnHash.startsWith("0x")) {
throw new Error(`Missing or malformed transaction hash: ${txnHash}`);
}

const provider = new ethers.providers.StaticJsonRpcProvider(getNodeUrlList(chainId, 1)[0]);
const spokePool = await getSpokePoolContract(chainId);

const txn = await provider.getTransactionReceipt(txnHash);
const fundsDeposited = spokePool.interface.getEventTopic("FundsDeposited");
const filledRelay = spokePool.interface.getEventTopic("FilledRelay");
const logs = txn.logs.filter(({ address }) => address === spokePool.address);
const { deposits = [], fills = [] } = groupBy(logs, ({ topics }) => {
switch (topics[0]) {
case fundsDeposited:
return "deposits";
case filledRelay:
return "fills";
}
});

deposits.forEach((deposit) => {
printDeposit(spokePool.interface.parseLog(deposit));
});

fills.forEach((fill) => {
printFill(spokePool.interface.parseLog(fill));
});

return true;
}

function usage(badInput?: string): boolean {
let usageStr = badInput ? `\nUnrecognized input: "${badInput}".\n\n` : "";
const walletOpts = "mnemonic|privateKey";
const depositArgs =
"--from <originChainId> --to <destinationChainId>" +
" --token <tokenSymbol> --amount <amount> [--recipient <recipient>] [--decimals]";
const dumpConfigArgs = "--chainId";
const fetchArgs = "--chainId <chainId> --txnHash <txnHash>";
const fillArgs = "--from <originChainId> --hash <depositHash>";

const pad = "deposit".length;
usageStr += `
Usage:
\tyarn ts-node ./scripts/spokepool --wallet <${walletOpts}> ${"deposit".padEnd(pad)} ${depositArgs}
\tyarn ts-node ./scripts/spokepool --wallet <${walletOpts}> ${"dump".padEnd(pad)} ${dumpConfigArgs}
\tyarn ts-node ./scripts/spokepool --wallet <${walletOpts}> ${"fetch".padEnd(pad)} ${fetchArgs}
\tyarn ts-node ./scripts/spokepool --wallet <${walletOpts}> ${"fill".padEnd(pad)} ${fillArgs}
`.slice(1); // Skip leading newline
console.log(usageStr);

// eslint-disable-next-line no-process-exit
process.exit(badInput === undefined ? 0 : 9);
// not reached
}

async function run(argv: string[]): Promise<boolean> {
const configOpts = ["chainId"];
const depositOpts = ["from", "to", "token", "amount", "recipient"];
const fetchOpts = ["chainId", "transactionHash"];
const fillOpts = [];
const opts = {
string: ["wallet", ...configOpts, ...depositOpts, ...fetchOpts, ...fillOpts],
boolean: ["decimals"], // @dev tbd whether this is good UX or not...may need to change.
default: {
wallet: "mnemonic",
decimals: false,
},
alias: {
transactionHash: "txnHash",
},
unknown: usage,
};
const args = minimist(argv.slice(1), opts);

config();

let signer: Wallet;
try {
signer = await getSigner({ keyType: args.wallet, cleanEnv: true });
} catch (err) {
usage(args.wallet); // no return
}

switch (argv[0]) {
case "deposit":
return await deposit(args, signer);
case "dump":
return await dumpConfig(args, signer);
case "fetch":
return await fetchTxn(args, signer);
case "fill":
// @todo Not supported yet...
usage(); // no return
pxrl marked this conversation as resolved.
Show resolved Hide resolved
break; // ...keep the linter less dissatisfied!
default:
usage(); // no return
}
}

if (require.main === module) {
run(process.argv.slice(2))
.then(async () => {
// eslint-disable-next-line no-process-exit
process.exit(0);
})
.catch(async (error) => {
console.error("Process exited with", error);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
}
17 changes: 15 additions & 2 deletions src/utils/AddressUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,23 @@ export function compareAddressesSimple(addressA: string, addressB: string): bool
export function matchTokenSymbol(tokenAddress: string, chainId: number): string[] {
// We can match one l1 token address on multiple symbols in some special cases, like ETH/WETH.
return Object.values(TOKEN_SYMBOLS_MAP)
.filter(({ addresses }) => addresses[chainId].toLowerCase() === tokenAddress.toLowerCase())
.filter(({ addresses }) => addresses[chainId]?.toLowerCase() === tokenAddress.toLowerCase())
.map(({ symbol }) => symbol);
}

/**
* Match the token decimals for a given token symbol.
* @param tokenSymbol Symbol of the token to query.
* @returns The number of ERC20 decimals configured for the requested token.
*/
export function resolveTokenDecimals(tokenSymbol: string): number {
const decimals = TOKEN_SYMBOLS_MAP[tokenSymbol]?.decimals;
if (decimals === undefined) {
throw new Error(`Unrecognized token symbol: ${tokenSymbol}`);
}
return decimals;
}

pxrl marked this conversation as resolved.
Show resolved Hide resolved
/**
* Resolves a list of token symbols for a list of token addresses and a chain ID.
* @param tokenAddresses The token addresses to resolve the symbols for.
Expand All @@ -43,7 +56,7 @@ export function resolveTokenSymbols(tokenAddresses: string[], chainId: number):
const tokenSymbols = Object.values(TOKEN_SYMBOLS_MAP);
return tokenAddresses
.map((tokenAddress) => {
return tokenSymbols.find(({ addresses }) => addresses[chainId].toLowerCase() === tokenAddress.toLowerCase())
return tokenSymbols.find(({ addresses }) => addresses[chainId]?.toLowerCase() === tokenAddress.toLowerCase())
?.symbol;
})
.filter(Boolean);
Expand Down