Spy relayer cleanup (#1015)

* initial spy-relayer

* Update spy_relayer Dockerfile

* added example mainnet config files

* split out private keys into its own ENV variable

* Update spy relayer supportedChains.json

To remove the `walletPrivateKey` entries. All of the private keys have
been split out into their own json file.

* fixed evm private key env parse

* missing solana accounts report 0 balance, rather than error

* wallet address is logged in debug

* spy_relayer: enabled prometheus default metrics

Also set a prefix of `relayer_`

* spy_relayer: updates to the prometheus bits

* Use a single metric registry
* Use a simpler metric name and add labels for individual wallets

* spy_relayer: human readable app mode in the metrics

[ listener | relayer | both ]

* spy_relayer: unify metrics

* remove the collection of default metrics
* hardcode the `spy_relayer_` prefix on all custom metrics

* fixed dep arrays, nullable terra token/balance info

* attempt stack debug

* debug pullTerraBalance

* provider http or ws

* update sdk

* logging for tokenAddress is 0

* fix foreign address calc

* fix calcLocalAddressesTerra

* relayer/spy_relayer: update prometheus helpers

Add / url handler for the ingress-gce stupid load balancer that
doesn't support custom url healthchecks unless you make a BackendConfig
custom resource definition.

* logging refinement

* use chain name in prometheus

* adjust retry timeout calculation

* spy_relayer: update prometheus bits

* improved error handling

* relayer ui improvements

* prep sdk release

* use latest sdk, manual redeem button

* relaying ux improvements

* gas price fix

* shortened terra success log

* use gh base relayer list

* fix prometheus urls

* Update prometheus metric name

* only show TPS warning on mainnet

* show relayer fee in source preview

* fix unwrap check

* add native bool to balance metric

* logging improvements

* add feeRecipientAddress to redeemOnSolana

* gather solana fees

* remove relayer ws support

* add nativeCurrencySymbol to ChainConfigInfo

* fix solana native symbol

* demoteWorking option, logger contexts

* scoped logging

* bridge_ui: unwrap native

* add evm wallet monitor test

* solana vaa parsing fix

* add monitorRedis

* make Jeff's brain happy

* log demoting keys

* register redisQueue metric

* human readable redisQueue metric

* fix timestamp inconsistency

* use scopedLogger for the first level of workers

* pull wallet balances in parallel

* more scoped logging

* pick a solana fee

* moving keys log improvement

* update eth gas calculations based on recent txs

* use postVaaSolanaWithRetry

* split success and failures by chain

* fix using terraCoin

* check prom every 10s

* batch getting evm token balances

* batch calcLocalAddressesEVM

* debug worker logging

* log retry number

* support Polygon?

* reset status on demotion

* enhance!

* update avax fee

Co-authored-by: Chase Moran <chasemoran45@gmail.com>
Co-authored-by: Kevin Peters <kpeters@jumptrading.com>
Co-authored-by: Evan Gray <battledingo@gmail.com>
This commit is contained in:
Jeff Schroeder 2022-03-28 23:39:08 -04:00 committed by GitHub
parent 1ae8ee4913
commit 349fa42c58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 20729 additions and 153 deletions

View File

@ -42,6 +42,7 @@ config.define_bool("algorand", False, "Enable Algorand component")
config.define_bool("solana", False, "Enable Solana component")
config.define_bool("explorer", False, "Enable explorer component")
config.define_bool("bridge_ui", False, "Enable bridge UI component")
config.define_bool("spy_relayer", False, "Enable spy relayer")
config.define_bool("e2e", False, "Enable E2E testing stack")
config.define_bool("ci_tests", False, "Enable tests runner component")
config.define_bool("bridge_ui_hot", False, "Enable hot loading bridge_ui")
@ -58,6 +59,7 @@ solana = cfg.get("solana", True)
ci = cfg.get("ci", False)
explorer = cfg.get("explorer", ci)
bridge_ui = cfg.get("bridge_ui", ci)
spy_relayer = cfg.get("spy_relayer", ci)
e2e = cfg.get("e2e", ci)
ci_tests = cfg.get("ci_tests", ci)
guardiand_debug = cfg.get("guardiand_debug", False)
@ -287,6 +289,60 @@ docker_build(
],
)
if spy_relayer:
docker_build(
ref = "redis",
context = ".",
only = ["./third_party"],
dockerfile = "third_party/redis/Dockerfile",
)
k8s_yaml_with_ns("devnet/redis.yaml")
k8s_resource(
"redis",
port_forwards = [
port_forward(6379, name = "Redis Default [:6379]", host = webHost),
],
labels = ["spy-relayer"],
trigger_mode = trigger_mode,
)
docker_build(
ref = "spy-relay-image",
context = ".",
only = ["./relayer/spy_relayer"],
dockerfile = "relayer/spy_relayer/Dockerfile",
live_update = []
)
k8s_yaml_with_ns("devnet/spy-listener.yaml")
k8s_resource(
"spy-listener",
resource_deps = ["proto-gen", "guardian", "redis"],
port_forwards = [
port_forward(6062, container_port = 6060, name = "Debug/Status Server [:6062]", host = webHost),
port_forward(4201, name = "REST [:4201]", host = webHost),
port_forward(8082, name = "Prometheus [:8082]", host = webHost),
],
labels = ["spy-relayer"],
trigger_mode = trigger_mode,
)
k8s_yaml_with_ns("devnet/spy-relayer.yaml")
k8s_resource(
"spy-relayer",
resource_deps = ["proto-gen", "guardian", "redis"],
port_forwards = [
port_forward(6063, container_port = 6060, name = "Debug/Status Server [:6063]", host = webHost),
port_forward(8083, name = "Prometheus [:8083]", host = webHost),
],
labels = ["spy-relayer"],
trigger_mode = trigger_mode,
)
k8s_yaml_with_ns("devnet/eth-devnet.yaml")
k8s_resource(

View File

@ -8,7 +8,7 @@
"name": "test_ui",
"version": "0.1.0",
"dependencies": {
"@certusone/wormhole-sdk": "^0.2.1",
"@certusone/wormhole-sdk": "^0.2.2",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.60",
@ -81,7 +81,7 @@
},
"../sdk/js": {
"name": "@certusone/wormhole-sdk",
"version": "0.2.1",
"version": "0.2.2",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
@ -2125,9 +2125,9 @@
}
},
"node_modules/@certusone/wormhole-sdk": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.2.1.tgz",
"integrity": "sha512-L85tiUHwnH4nbUEDgQtS2hNm3Q0IsUP29Z/DGbN2zggdvR0KTC6nLQ+LufCM6IcdUQYpYuwXjOYKD1Et8qc0mw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.2.2.tgz",
"integrity": "sha512-TlPNm/XRAVPwn7kh2y0sxh+7YjQIoC+LEJca5OR+0ak2bl2IbJmn58MhlcN0tJiZ6qDG5/we5XUifHSRKHhBww==",
"dependencies": {
"@improbable-eng/grpc-web": "^0.14.0",
"@solana/spl-token": "^0.1.8",
@ -45855,9 +45855,9 @@
}
},
"@certusone/wormhole-sdk": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.2.1.tgz",
"integrity": "sha512-L85tiUHwnH4nbUEDgQtS2hNm3Q0IsUP29Z/DGbN2zggdvR0KTC6nLQ+LufCM6IcdUQYpYuwXjOYKD1Et8qc0mw==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@certusone/wormhole-sdk/-/wormhole-sdk-0.2.2.tgz",
"integrity": "sha512-TlPNm/XRAVPwn7kh2y0sxh+7YjQIoC+LEJca5OR+0ak2bl2IbJmn58MhlcN0tJiZ6qDG5/we5XUifHSRKHhBww==",
"requires": {
"@improbable-eng/grpc-web": "^0.14.0",
"@solana/spl-token": "^0.1.8",

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@certusone/wormhole-sdk": "^0.2.1",
"@certusone/wormhole-sdk": "^0.2.2",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.60",

View File

@ -0,0 +1,25 @@
{
"supportedTokens": [
{
"chainId": 1,
"address": "So11111111111111111111111111111111111111112",
"coingeckoId": "solana"
},
{
"chainId": 2,
"address": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"coingeckoId": "ethereum"
},
{
"chainId": 3,
"address": "uluna",
"coingeckoId": "terra-luna"
},
{
"chainId": 4,
"address": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"coingeckoId": "binancecoin"
}
],
"relayers": [{ "name": "localhostRelayer", "url": "http://localhost:4201" }]
}

View File

@ -38,6 +38,7 @@ import Recovery from "./components/Recovery";
import Stats from "./components/Stats";
import TokenOriginVerifier from "./components/TokenOriginVerifier";
import Transfer from "./components/Transfer";
import UnwrapNative from "./components/UnwrapNative";
import WithdrawTokensTerra from "./components/WithdrawTokensTerra";
import { useBetaContext } from "./contexts/BetaContext";
import Portal from "./icons/portal_logo.svg";
@ -366,6 +367,9 @@ function App() {
<Route exact path="/withdraw-tokens-terra">
<WithdrawTokensTerra />
</Route>
<Route exact path="/unwrap-native">
<UnwrapNative />
</Route>
<Route>
<Redirect to="/transfer" />
</Route>

View File

@ -0,0 +1,237 @@
import { CHAIN_ID_TERRA, isEVMChain } from "@certusone/wormhole-sdk";
import {
Card,
Checkbox,
Chip,
makeStyles,
Typography,
} from "@material-ui/core";
import clsx from "clsx";
import { useCallback, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import SmartAddress from "../components/SmartAddress";
import useRelayerInfo from "../hooks/useRelayerInfo";
import { GasEstimateSummary } from "../hooks/useTransactionFees";
import { COLORS } from "../muiTheme";
import {
selectTransferOriginAsset,
selectTransferOriginChain,
selectTransferSourceChain,
selectTransferSourceParsedTokenAccount,
selectTransferTargetChain,
selectTransferUseRelayer,
} from "../store/selectors";
import { setRelayerFee, setUseRelayer } from "../store/transferSlice";
import { CHAINS_BY_ID, getDefaultNativeCurrencySymbol } from "../utils/consts";
const useStyles = makeStyles((theme) => ({
feeSelectorContainer: {
marginTop: "2rem",
textAlign: "center",
},
title: {
margin: theme.spacing(2),
},
optionCardBase: {
display: "flex",
margin: theme.spacing(2),
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing(1),
background: COLORS.nearBlackWithMinorTransparency,
"& > *": {
margin: ".5rem",
},
border: "1px solid " + COLORS.nearBlackWithMinorTransparency,
},
alignCenterContainer: {
alignItems: "center",
display: "flex",
"& > *": {
margin: "0rem 1rem 0rem 1rem",
},
},
optionCardSelectable: {
"&:hover": {
cursor: "pointer",
boxShadow: "inset 0 0 100px 100px rgba(255, 255, 255, 0.1)",
},
},
optionCardSelected: {
border: "1px solid " + COLORS.blue,
},
inlineBlock: {
display: "inline-block",
},
alignLeft: {
textAlign: "left",
},
betaLabel: {
color: COLORS.white,
background: "linear-gradient(20deg, #f44b1b 0%, #eeb430 100%)",
marginLeft: theme.spacing(1),
fontSize: "120%",
},
}));
function FeeMethodSelector() {
const classes = useStyles();
const originAsset = useSelector(selectTransferOriginAsset);
const originChain = useSelector(selectTransferOriginChain);
const targetChain = useSelector(selectTransferTargetChain);
const relayerInfo = useRelayerInfo(originChain, originAsset, targetChain);
const dispatch = useDispatch();
const relayerSelected = !!useSelector(selectTransferUseRelayer);
const sourceParsedTokenAccount = useSelector(
selectTransferSourceParsedTokenAccount
);
const sourceSymbol = sourceParsedTokenAccount?.symbol;
const sourceChain = useSelector(selectTransferSourceChain);
console.log("relayer info in fee method selector", relayerInfo);
const relayerEligible =
relayerInfo.data &&
relayerInfo.data.isRelayable &&
relayerInfo.data.feeFormatted &&
relayerInfo.data.feeUsd;
const chooseRelayer = useCallback(() => {
if (relayerEligible) {
dispatch(setUseRelayer(true));
dispatch(setRelayerFee(relayerInfo.data?.feeFormatted));
}
}, [relayerInfo, dispatch, relayerEligible]);
const chooseManual = useCallback(() => {
dispatch(setUseRelayer(false));
dispatch(setRelayerFee(undefined));
}, [dispatch]);
useEffect(() => {
if (relayerInfo.data?.isRelayable === true) {
chooseRelayer();
} else if (relayerInfo.data?.isRelayable === false) {
chooseManual();
}
//If it's undefined / null it's still loading, so no action is taken.
}, [relayerInfo, chooseRelayer, chooseManual]);
const relayerContent = (
<Card
className={
classes.optionCardBase +
" " +
(relayerSelected ? classes.optionCardSelected : "") +
" " +
(relayerEligible ? classes.optionCardSelectable : "")
}
onClick={chooseRelayer}
>
<div className={classes.alignCenterContainer}>
<Checkbox
checked={relayerSelected}
disabled={!relayerEligible}
onClick={chooseRelayer}
className={classes.inlineBlock}
/>
<div className={clsx(classes.inlineBlock, classes.alignLeft)}>
{relayerEligible ? (
<div>
<Typography variant="body1">Automatic Payment</Typography>
<Typography variant="body2" color="textSecondary">
{`Pay with additional ${
sourceSymbol ? sourceSymbol : "tokens"
} and use a relayer`}
</Typography>
</div>
) : (
<>
<Typography color="textSecondary" variant="body2">
{"Automatic redeem is unavailable for this token."}
</Typography>
<div />
</>
)}
</div>
</div>
{/* TODO fixed number of decimals on these strings */}
{relayerEligible ? (
<>
<div>
<Chip label="Beta" className={classes.betaLabel} />
</div>
<div>
<div>
<Typography className={classes.inlineBlock}>
{/* Transfers are max 8 decimals */}
{parseFloat(relayerInfo.data?.feeFormatted || "0").toFixed(
Math.min(sourceParsedTokenAccount?.decimals || 8, 8)
)}
</Typography>
<SmartAddress
chainId={sourceChain}
parsedTokenAccount={sourceParsedTokenAccount}
/>
</div>{" "}
<Typography>{`($ ${relayerInfo.data?.feeUsd})`}</Typography>
</div>
</>
) : null}
</Card>
);
const manualRedeemContent = (
<Card
className={
classes.optionCardBase +
" " +
classes.optionCardSelectable +
" " +
(!relayerSelected ? classes.optionCardSelected : "")
}
onClick={chooseManual}
>
<div className={classes.alignCenterContainer}>
<Checkbox
checked={!relayerSelected}
onClick={chooseManual}
className={classes.inlineBlock}
/>
<div className={clsx(classes.inlineBlock, classes.alignLeft)}>
<Typography variant="body1">{"Manual Payment"}</Typography>
<Typography variant="body2" color="textSecondary">
{`Pay with your own ${
targetChain === CHAIN_ID_TERRA
? "funds"
: getDefaultNativeCurrencySymbol(targetChain)
} on ${CHAINS_BY_ID[targetChain]?.name || "target chain"}`}
</Typography>
</div>
</div>
{(isEVMChain(targetChain) || targetChain === CHAIN_ID_TERRA) && (
<GasEstimateSummary
methodType="transfer"
chainId={targetChain}
priceQuote={relayerInfo.data?.targetNativeAssetPriceQuote}
/>
)}
</Card>
);
return (
<div className={classes.feeSelectorContainer}>
<Typography
className={classes.title}
variant="subtitle2"
color="textSecondary"
>
How would you like to pay the target chain fees?
</Typography>
{relayerContent}
{manualRedeemContent}
</div>
);
}
export default FeeMethodSelector;

View File

@ -1,8 +1,10 @@
import { ChainId, CHAIN_ID_TERRA } from "@certusone/wormhole-sdk";
import { makeStyles, Typography } from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { useSelector } from "react-redux";
import useIsWalletReady from "../hooks/useIsWalletReady";
import useTransactionFees from "../hooks/useTransactionFees";
import { selectTransferUseRelayer } from "../store/selectors";
import { getDefaultNativeCurrencySymbol } from "../utils/consts";
const useStyles = makeStyles((theme) => ({
@ -16,8 +18,11 @@ function LowBalanceWarning({ chainId }: { chainId: ChainId }) {
const classes = useStyles();
const { isReady } = useIsWalletReady(chainId);
const transactionFeeWarning = useTransactionFees(chainId);
const relayerSelected = !!useSelector(selectTransferUseRelayer);
const displayWarning =
isReady &&
!relayerSelected &&
(chainId === CHAIN_ID_TERRA || transactionFeeWarning.balanceString) &&
transactionFeeWarning.isSufficientBalance === false;

View File

@ -3,6 +3,7 @@ import { useSelector } from "react-redux";
import { useHandleNFTRedeem } from "../../hooks/useHandleNFTRedeem";
import useIsWalletReady from "../../hooks/useIsWalletReady";
import { selectNFTTargetChain } from "../../store/selectors";
import { CLUSTER } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
import SolanaTPSWarning from "../SolanaTPSWarning";
@ -21,7 +22,9 @@ function Redeem() {
{targetChain === CHAIN_ID_TERRA && (
<TerraFeeDenomPicker disabled={disabled} />
)}
{targetChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{targetChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
<ButtonWithLoader
disabled={!isReady || disabled}
onClick={handleClick}

View File

@ -10,7 +10,7 @@ import {
selectNFTTransferTx,
selectNFTIsSendComplete,
} from "../../store/selectors";
import { CHAINS_BY_ID } from "../../utils/consts";
import { CHAINS_BY_ID, CLUSTER } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
import ShowTx from "../ShowTx";
@ -53,7 +53,9 @@ function Send() {
completing Step 4, you will have to perform the recovery workflow to
complete the transfer.
</Alert>
{sourceChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{sourceChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
<ButtonWithLoader
disabled={isDisabled}
onClick={handleClick}

View File

@ -16,6 +16,7 @@ import {
} from "../../store/selectors";
import {
CHAINS_WITH_NFT_SUPPORT,
CLUSTER,
getIsTransferDisabled,
} from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
@ -99,7 +100,9 @@ function Source() {
</div>
) : null}
<LowBalanceWarning chainId={sourceChain} />
{sourceChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{sourceChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
<ChainWarningMessage chainId={sourceChain} />
<ButtonWithLoader
disabled={!isSourceComplete || isTransferDisabled}

View File

@ -29,6 +29,7 @@ import {
import {
CHAINS_BY_ID,
CHAINS_WITH_NFT_SUPPORT,
CLUSTER,
getIsTransferDisabled,
} from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
@ -145,7 +146,9 @@ function Target() {
)}
</Alert>
<LowBalanceWarning chainId={targetChain} />
{targetChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{targetChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
<ChainWarningMessage chainId={targetChain} />
<ButtonWithLoader
disabled={!isTargetComplete || isTransferDisabled} //|| !associatedAccountExists}

View File

@ -27,11 +27,13 @@ import {
makeStyles,
MenuItem,
TextField,
Typography,
} from "@material-ui/core";
import { ExpandMore } from "@material-ui/icons";
import { Alert } from "@material-ui/lab";
import { Connection } from "@solana/web3.js";
import { LCDClient } from "@terra-money/terra.js";
import axios from "axios";
import { ethers } from "ethers";
import { useSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useState } from "react";
@ -39,6 +41,7 @@ import { useDispatch } from "react-redux";
import { useHistory, useLocation } from "react-router";
import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import useIsWalletReady from "../hooks/useIsWalletReady";
import useRelayersAvailable, { Relayer } from "../hooks/useRelayersAvailable";
import { COLORS } from "../muiTheme";
import { setRecoveryVaa as setRecoveryNFTVaa } from "../store/nftSlice";
import { setRecoveryVaa } from "../store/transferSlice";
@ -49,6 +52,7 @@ import {
getBridgeAddressForChain,
getNFTBridgeAddressForChain,
getTokenBridgeAddressForChain,
RELAY_URL_EXTENSION,
SOLANA_HOST,
SOL_NFT_BRIDGE_ADDRESS,
SOL_TOKEN_BRIDGE_ADDRESS,
@ -61,6 +65,7 @@ import parseError from "../utils/parseError";
import ButtonWithLoader from "./ButtonWithLoader";
import ChainSelect from "./ChainSelect";
import KeyAndBalance from "./KeyAndBalance";
import RelaySelector from "./RelaySelector";
const useStyles = makeStyles((theme) => ({
mainCard: {
@ -70,6 +75,13 @@ const useStyles = makeStyles((theme) => ({
advancedContainer: {
padding: theme.spacing(2, 0),
},
relayAlert: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
"& > .MuiAlert-message": {
width: "100%",
},
},
}));
async function evm(
@ -160,6 +172,94 @@ async function terra(tx: string, enqueueSnackbar: any) {
}
}
function RelayerRecovery({
parsedPayload,
signedVaa,
onClick,
}: {
parsedPayload: any;
signedVaa: string;
onClick: () => void;
}) {
const classes = useStyles();
const relayerInfo = useRelayersAvailable(true);
const [selectedRelayer, setSelectedRelayer] = useState<Relayer | null>(null);
const [isAttemptingToSchedule, setIsAttemptingToSchedule] = useState(false);
const { enqueueSnackbar } = useSnackbar();
console.log(parsedPayload, relayerInfo, "in recovery relayer");
const fee =
(parsedPayload && parsedPayload.fee && parseInt(parsedPayload.fee)) || null;
//This check is probably more sophisticated in the future. Possibly a net call.
const isEligible =
fee &&
fee > 0 &&
relayerInfo?.data?.relayers?.length &&
relayerInfo?.data?.relayers?.length > 0;
const handleRelayerChange = useCallback(
(relayer: Relayer | null) => {
setSelectedRelayer(relayer);
},
[setSelectedRelayer]
);
const handleGo = useCallback(async () => {
console.log("handle go", selectedRelayer, parsedPayload);
if (!(selectedRelayer && selectedRelayer.url)) {
return;
}
setIsAttemptingToSchedule(true);
axios
.get(
selectedRelayer.url +
RELAY_URL_EXTENSION +
encodeURIComponent(
Buffer.from(hexToUint8Array(signedVaa)).toString("base64")
)
)
.then(
() => {
setIsAttemptingToSchedule(false);
onClick();
},
(error) => {
setIsAttemptingToSchedule(false);
enqueueSnackbar(null, {
content: (
<Alert severity="error">
{"Relay request rejected. Error: " + error.message}
</Alert>
),
});
}
);
}, [selectedRelayer, enqueueSnackbar, onClick, signedVaa, parsedPayload]);
if (!isEligible) {
return null;
}
return (
<Alert variant="outlined" severity="info" className={classes.relayAlert}>
<Typography>{"This transaction is eligible to be relayed"}</Typography>
<RelaySelector
selectedValue={selectedRelayer}
onChange={handleRelayerChange}
/>
<ButtonWithLoader
disabled={!selectedRelayer}
onClick={handleGo}
showLoader={isAttemptingToSchedule}
>
Request Relay
</ButtonWithLoader>
</Alert>
);
}
export default function Recovery() {
const classes = useStyles();
const { push } = useHistory();
@ -338,50 +438,64 @@ export default function Recovery() {
}, [recoverySignedVAA]);
const parsedPayloadTargetChain = parsedPayload?.targetChain;
const enableRecovery = recoverySignedVAA && parsedPayloadTargetChain;
const handleRecoverClick = useCallback(() => {
if (enableRecovery && recoverySignedVAA && parsedPayloadTargetChain) {
// TODO: make recovery reducer
if (isNFT) {
dispatch(
setRecoveryNFTVaa({
vaa: recoverySignedVAA,
parsedPayload: {
targetChain: parsedPayload.targetChain,
targetAddress: parsedPayload.targetAddress,
originChain: parsedPayload.originChain,
originAddress: parsedPayload.originAddress,
},
})
);
push("/nft");
} else {
dispatch(
setRecoveryVaa({
vaa: recoverySignedVAA,
parsedPayload: {
targetChain: parsedPayload.targetChain,
targetAddress: parsedPayload.targetAddress,
originChain: parsedPayload.originChain,
originAddress: parsedPayload.originAddress,
amount:
"amount" in parsedPayload
? parsedPayload.amount.toString()
: "",
},
})
);
push("/transfer");
const handleRecoverClickBase = useCallback(
(useRelayer: boolean) => {
if (enableRecovery && recoverySignedVAA && parsedPayloadTargetChain) {
// TODO: make recovery reducer
if (isNFT) {
dispatch(
setRecoveryNFTVaa({
vaa: recoverySignedVAA,
parsedPayload: {
targetChain: parsedPayload.targetChain,
targetAddress: parsedPayload.targetAddress,
originChain: parsedPayload.originChain,
originAddress: parsedPayload.originAddress,
},
})
);
push("/nft");
} else {
dispatch(
setRecoveryVaa({
vaa: recoverySignedVAA,
useRelayer,
parsedPayload: {
targetChain: parsedPayload.targetChain,
targetAddress: parsedPayload.targetAddress,
originChain: parsedPayload.originChain,
originAddress: parsedPayload.originAddress,
amount:
"amount" in parsedPayload
? parsedPayload.amount.toString()
: "",
},
})
);
push("/transfer");
}
}
}
}, [
dispatch,
enableRecovery,
recoverySignedVAA,
parsedPayloadTargetChain,
parsedPayload,
isNFT,
push,
]);
},
[
dispatch,
enableRecovery,
recoverySignedVAA,
parsedPayloadTargetChain,
parsedPayload,
isNFT,
push,
]
);
const handleRecoverClick = useCallback(() => {
handleRecoverClickBase(false);
}, [handleRecoverClickBase]);
const handleRecoverWithRelayerClick = useCallback(() => {
handleRecoverClickBase(true);
}, [handleRecoverClickBase]);
return (
<Container maxWidth="md">
<Card className={classes.mainCard}>
@ -431,6 +545,11 @@ export default function Recovery() {
fullWidth
margin="normal"
/>
<RelayerRecovery
parsedPayload={parsedPayload}
signedVaa={recoverySignedVAA}
onClick={handleRecoverWithRelayerClick}
/>
<ButtonWithLoader
onClick={handleRecoverClick}
disabled={!enableRecovery}
@ -582,15 +701,26 @@ export default function Recovery() {
margin="normal"
/>
{isNFT ? null : (
<TextField
variant="outlined"
label="Amount"
disabled
// @ts-ignore
value={parsedPayload?.amount.toString() || ""}
fullWidth
margin="normal"
/>
<>
<TextField
variant="outlined"
label="Amount"
disabled
// @ts-ignore
value={parsedPayload?.amount.toString() || ""}
fullWidth
margin="normal"
/>
<TextField
variant="outlined"
label="Relayer Fee"
disabled
// @ts-ignore
value={parsedPayload?.fee.toString() || ""}
fullWidth
margin="normal"
/>
</>
)}
</div>
</AccordionDetails>

View File

@ -0,0 +1,81 @@
import {
CircularProgress,
makeStyles,
MenuItem,
TextField,
Typography,
} from "@material-ui/core";
import { useCallback } from "react";
import useRelayersAvailable, { Relayer } from "../hooks/useRelayersAvailable";
const useStyles = makeStyles((theme) => ({
mainContainer: {
textAlign: "center",
},
}));
export default function RelaySelector({
selectedValue,
onChange,
}: {
selectedValue: Relayer | null;
onChange: (newValue: Relayer | null) => void;
}) {
const classes = useStyles();
const availableRelayers = useRelayersAvailable(true);
const loader = (
<div>
<CircularProgress></CircularProgress>
<Typography>Loading available relayers</Typography>
</div>
);
const onChangeWrapper = useCallback(
(event) => {
console.log(event, "event in selector");
event.target.value
? onChange(
availableRelayers?.data?.relayers?.find(
(x) => x.url === event.target.value
) || null
)
: onChange(null);
},
[onChange, availableRelayers]
);
console.log("selectedValue in relay selector", selectedValue);
const selector = (
<TextField
onChange={onChangeWrapper}
value={selectedValue ? selectedValue.url : ""}
label="Select a relayer"
select
fullWidth
>
{availableRelayers.data?.relayers?.map((item) => (
<MenuItem key={item.url} value={item.url}>
{item.name}
</MenuItem>
))}
</TextField>
);
const error = (
<Typography variant="body2" color="textSecondary">
No relayers are available at this time.
</Typography>
);
return (
<div className={classes.mainContainer}>
{availableRelayers.data?.relayers?.length
? selector
: availableRelayers.isFetching
? loader
: error}
</div>
);
}

View File

@ -13,10 +13,14 @@ import {
WSOL_ADDRESS,
} from "@certusone/wormhole-sdk";
import {
Button,
Checkbox,
CircularProgress,
FormControlLabel,
Link,
makeStyles,
Tooltip,
Typography,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { useCallback, useState } from "react";
@ -28,9 +32,11 @@ import {
selectTransferIsRecovery,
selectTransferTargetAsset,
selectTransferTargetChain,
selectTransferUseRelayer,
} from "../../store/selectors";
import { reset } from "../../store/transferSlice";
import {
CLUSTER,
getHowToAddTokensToWalletUrl,
ROPSTEN_WETH_ADDRESS,
WAVAX_ADDRESS,
@ -49,6 +55,7 @@ import SolanaTPSWarning from "../SolanaTPSWarning";
import StepDescription from "../StepDescription";
import TerraFeeDenomPicker from "../TerraFeeDenomPicker";
import AddToMetamask from "./AddToMetamask";
import RedeemPreview from "./RedeemPreview";
import WaitingForWalletMessage from "./WaitingForWalletMessage";
const useStyles = makeStyles((theme) => ({
@ -56,16 +63,28 @@ const useStyles = makeStyles((theme) => ({
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
centered: {
margin: theme.spacing(4, 0, 2),
textAlign: "center",
},
}));
function Redeem() {
const { handleClick, handleNativeClick, disabled, showLoader } =
useHandleRedeem();
const useRelayer = useSelector(selectTransferUseRelayer);
const [manualRedeem, setManualRedeem] = useState(!useRelayer);
const handleManuallyRedeemClick = useCallback(() => {
setManualRedeem(true);
}, []);
const targetChain = useSelector(selectTransferTargetChain);
const targetAsset = useSelector(selectTransferTargetAsset);
const isRecovery = useSelector(selectTransferIsRecovery);
const { isTransferCompletedLoading, isTransferCompleted } =
useGetIsTransferCompleted(true);
useGetIsTransferCompleted(
useRelayer ? false : true,
useRelayer ? 5000 : undefined
);
const classes = useStyles();
const dispatch = useDispatch();
const { isReady, statusMessage } = useIsWalletReady(targetChain);
@ -125,9 +144,45 @@ function Redeem() {
}, [dispatch]);
const howToAddTokensUrl = getHowToAddTokensToWalletUrl(targetChain);
return (
const relayerContent = (
<>
{isEVMChain(targetChain) && !isTransferCompleted ? (
<KeyAndBalance chainId={targetChain} />
) : null}
{!isReady && isEVMChain(targetChain) && !isTransferCompleted ? (
<Typography className={classes.centered}>
{"Please connect your wallet to check for transfer completion."}
</Typography>
) : null}
{(!isEVMChain(targetChain) || isReady) && !isTransferCompleted ? (
<div className={classes.centered}>
<CircularProgress style={{ marginBottom: 16 }} />
<Typography>
{"Waiting for a relayer to process your transfer."}
</Typography>
<Tooltip title="Your fees will be refunded on the target chain">
<Button
onClick={handleManuallyRedeemClick}
size="small"
variant="outlined"
style={{ marginTop: 16 }}
>
Manually redeem instead
</Button>
</Tooltip>
</div>
) : null}
{isTransferCompleted ? (
<RedeemPreview overrideExplainerString="Success! Your transfer is complete." />
) : null}
</>
);
const nonRelayContent = (
<>
<StepDescription>Receive the tokens on the target chain</StepDescription>
<KeyAndBalance chainId={targetChain} />
{targetChain === CHAIN_ID_TERRA && (
<TerraFeeDenomPicker disabled={disabled} />
@ -144,27 +199,34 @@ function Redeem() {
label="Automatically unwrap to native currency"
/>
)}
{targetChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{targetChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
{targetChain === CHAIN_ID_SOLANA ? (
<SolanaCreateAssociatedAddressAlternate />
) : null}
<ButtonWithLoader
//TODO disable when the associated token account is confirmed to not exist
disabled={
!isReady ||
disabled ||
(isRecovery && (isTransferCompletedLoading || isTransferCompleted))
}
onClick={
isNativeEligible && useNativeRedeem ? handleNativeClick : handleClick
}
showLoader={showLoader || (isRecovery && isTransferCompletedLoading)}
error={statusMessage}
>
Redeem
</ButtonWithLoader>
<WaitingForWalletMessage />
<>
{" "}
<ButtonWithLoader
//TODO disable when the associated token account is confirmed to not exist
disabled={
!isReady ||
disabled ||
(isRecovery && (isTransferCompletedLoading || isTransferCompleted))
}
onClick={
isNativeEligible && useNativeRedeem
? handleNativeClick
: handleClick
}
showLoader={showLoader || (isRecovery && isTransferCompletedLoading)}
error={statusMessage}
>
Redeem
</ButtonWithLoader>
<WaitingForWalletMessage />
</>
{isRecovery && isReady && isTransferCompleted ? (
<>
@ -197,6 +259,13 @@ function Redeem() {
) : null}
</>
);
return (
<>
<StepDescription>Receive the tokens on the target chain</StepDescription>
{manualRedeem ? nonRelayContent : relayerContent}
</>
);
}
export default Redeem;

View File

@ -14,10 +14,15 @@ import FeaturedMarkets from "./FeaturedMarkets";
const useStyles = makeStyles((theme) => ({
description: {
textAlign: "center",
marginBottom: theme.spacing(2),
},
}));
export default function RedeemPreview() {
export default function RedeemPreview({
overrideExplainerString,
}: {
overrideExplainerString?: string;
}) {
const classes = useStyles();
const dispatch = useDispatch();
const targetChain = useSelector(selectTransferTargetChain);
@ -27,13 +32,14 @@ export default function RedeemPreview() {
}, [dispatch]);
const explainerString =
overrideExplainerString ||
"Success! The redeem transaction was submitted. The tokens will become available once the transaction confirms.";
return (
<>
<Typography
component="div"
variant="subtitle2"
variant="subtitle1"
className={classes.description}
>
{explainerString}

View File

@ -6,7 +6,7 @@ import {
import { Checkbox, FormControlLabel } from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { ethers } from "ethers";
import { parseUnits } from "ethers/lib/utils";
import { formatUnits, parseUnits } from "ethers/lib/utils";
import { useCallback, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import useAllowance from "../../hooks/useAllowance";
@ -16,13 +16,14 @@ import {
selectSourceWalletAddress,
selectTransferAmount,
selectTransferIsSendComplete,
selectTransferRelayerFee,
selectTransferSourceAsset,
selectTransferSourceChain,
selectTransferSourceParsedTokenAccount,
selectTransferTargetError,
selectTransferTransferTx,
} from "../../store/selectors";
import { CHAINS_BY_ID } from "../../utils/consts";
import { CHAINS_BY_ID, CLUSTER } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
import ShowTx from "../ShowTx";
@ -53,13 +54,25 @@ function Send() {
const sourceParsedTokenAccount = useSelector(
selectTransferSourceParsedTokenAccount
);
const relayerFee = useSelector(selectTransferRelayerFee);
const sourceDecimals = sourceParsedTokenAccount?.decimals;
const sourceIsNative = sourceParsedTokenAccount?.isNativeAsset;
const sourceAmountParsed =
const baseAmountParsed =
sourceDecimals !== undefined &&
sourceDecimals !== null &&
sourceAmount &&
parseUnits(sourceAmount, sourceDecimals).toBigInt();
parseUnits(sourceAmount, sourceDecimals);
const feeParsed =
sourceDecimals !== undefined
? parseUnits(relayerFee || "0", sourceDecimals)
: 0;
const transferAmountParsed =
baseAmountParsed && baseAmountParsed.add(feeParsed).toBigInt();
const humanReadableTransferAmount =
sourceDecimals !== undefined &&
sourceDecimals !== null &&
transferAmountParsed &&
formatUnits(transferAmountParsed, sourceDecimals);
const oneParsed =
sourceDecimals !== undefined &&
sourceDecimals !== null &&
@ -91,12 +104,12 @@ function Send() {
} = useAllowance(
sourceChain,
sourceAsset,
sourceAmountParsed || undefined,
transferAmountParsed || undefined,
sourceIsNative
);
const approveButtonNeeded = isEVMChain(sourceChain) && !sufficientAllowance;
const notOne = shouldApproveUnlimited || sourceAmountParsed !== oneParsed;
const notOne = shouldApproveUnlimited || transferAmountParsed !== oneParsed;
const isDisabled =
!isReady ||
isWrongWallet ||
@ -110,14 +123,14 @@ function Send() {
const approveExactAmount = useMemo(() => {
return () => {
setAllowanceError("");
approveAmount(BigInt(sourceAmountParsed)).then(
approveAmount(BigInt(transferAmountParsed)).then(
() => {
setAllowanceError("");
},
(error) => setAllowanceError("Failed to approve the token transfer.")
);
};
}, [approveAmount, sourceAmountParsed]);
}, [approveAmount, transferAmountParsed]);
const approveUnlimited = useMemo(() => {
return () => {
setAllowanceError("");
@ -145,7 +158,9 @@ function Send() {
completing Step 4, you will have to perform the recovery workflow to
complete the transfer.
</Alert>
{sourceChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{sourceChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
{approveButtonNeeded ? (
<>
<FormControlLabel
@ -167,7 +182,11 @@ function Send() {
error={errorMessage}
>
{"Approve " +
(shouldApproveUnlimited ? "Unlimited" : sourceAmount) +
(shouldApproveUnlimited
? "Unlimited"
: humanReadableTransferAmount
? humanReadableTransferAmount
: sourceAmount) +
` Token${notOne ? "s" : ""}`}
</ButtonWithLoader>
</>

View File

@ -15,7 +15,7 @@ import {
selectTransferSourceChain,
selectTransferSourceParsedTokenAccount,
} from "../../store/selectors";
import { CHAINS_BY_ID, MULTI_CHAIN_TOKENS } from "../../utils/consts";
import { CHAINS_BY_ID, CLUSTER, MULTI_CHAIN_TOKENS } from "../../utils/consts";
import SmartAddress from "../SmartAddress";
import SolanaTPSWarning from "../SolanaTPSWarning";
import { useTargetInfo } from "./Target";
@ -125,7 +125,9 @@ function SendConfirmationContent({
targetAsset={targetAsset ?? undefined}
targetChain={targetChain}
/>
{sourceChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{sourceChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={onClose}>

View File

@ -30,6 +30,7 @@ import {
import {
BSC_MIGRATION_ASSET_MAP,
CHAINS,
CLUSTER,
ETH_MIGRATION_ASSET_MAP,
getIsTransferDisabled,
MIGRATION_ASSET_MAP,
@ -222,7 +223,9 @@ function Source() {
) : (
<>
<LowBalanceWarning chainId={sourceChain} />
{sourceChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{sourceChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
<SourceAssetWarning
sourceChain={sourceChain}
sourceAsset={parsedTokenAccount?.mintKey}

View File

@ -1,8 +1,10 @@
import { makeStyles, Typography } from "@material-ui/core";
import numeral from "numeral";
import { useSelector } from "react-redux";
import {
selectSourceWalletAddress,
selectTransferAmount,
selectTransferRelayerFee,
selectTransferSourceChain,
selectTransferSourceParsedTokenAccount,
} from "../../store/selectors";
@ -23,11 +25,17 @@ export default function SourcePreview() {
);
const sourceWalletAddress = useSelector(selectSourceWalletAddress);
const sourceAmount = useSelector(selectTransferAmount);
const relayerFee = useSelector(selectTransferRelayerFee);
const explainerContent =
sourceChain && sourceParsedTokenAccount ? (
<>
<span>You will transfer {sourceAmount}</span>
<span>
You will transfer {sourceAmount}{" "}
{relayerFee
? `(+~${numeral(relayerFee).format("0.00")} relayer fee)`
: ""}
</span>
<SmartAddress
chainId={sourceChain}
parsedTokenAccount={sourceParsedTokenAccount}

View File

@ -1,17 +1,14 @@
import {
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
hexToNativeString,
isEVMChain,
} from "@certusone/wormhole-sdk";
import { makeStyles, Typography } from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import useGetTargetParsedTokenAccounts from "../../hooks/useGetTargetParsedTokenAccounts";
import useIsWalletReady from "../../hooks/useIsWalletReady";
import useSyncTargetAddress from "../../hooks/useSyncTargetAddress";
import { GasEstimateSummary } from "../../hooks/useTransactionFees";
import {
selectTransferAmount,
selectTransferIsTargetComplete,
@ -26,9 +23,10 @@ import {
selectTransferTargetParsedTokenAccount,
} from "../../store/selectors";
import { incrementStep, setTargetChain } from "../../store/transferSlice";
import { CHAINS, CHAINS_BY_ID } from "../../utils/consts";
import { CHAINS, CLUSTER } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import ChainSelect from "../ChainSelect";
import FeeMethodSelector from "../FeeMethodSelector";
import KeyAndBalance from "../KeyAndBalance";
import LowBalanceWarning from "../LowBalanceWarning";
import SmartAddress from "../SmartAddress";
@ -99,7 +97,7 @@ function Target() {
const error = useSelector(selectTransferTargetError);
const isTargetComplete = useSelector(selectTransferIsTargetComplete);
const shouldLockFields = useSelector(selectTransferShouldLockFields);
const { statusMessage } = useIsWalletReady(targetChain);
const { statusMessage, isReady } = useIsWalletReady(targetChain);
const isLoading = !statusMessage && !targetAssetError && !data;
const { associatedAccountExists, setAssociatedAccountExists } =
useAssociatedAccountExistsState(
@ -169,17 +167,17 @@ function Target() {
setAssociatedAccountExists={setAssociatedAccountExists}
/>
) : null}
<Alert severity="info" variant="outlined" className={classes.alert}>
{/* <Alert severity="info" variant="outlined" className={classes.alert}>
<Typography>
You will have to pay transaction fees on{" "}
{CHAINS_BY_ID[targetChain].name} to redeem your tokens.
</Typography>
{(isEVMChain(targetChain) || targetChain === CHAIN_ID_TERRA) && (
<GasEstimateSummary methodType="transfer" chainId={targetChain} />
)}
</Alert>
</Alert> */}
{isEVMChain(targetChain) && !isReady ? null : <FeeMethodSelector />}
<LowBalanceWarning chainId={targetChain} />
{targetChain === CHAIN_ID_SOLANA && <SolanaTPSWarning />}
{targetChain === CHAIN_ID_SOLANA && CLUSTER === "mainnet" && (
<SolanaTPSWarning />
)}
<ButtonWithLoader
disabled={!isTargetComplete || !associatedAccountExists}
onClick={handleNextClick}

View File

@ -0,0 +1,274 @@
import {
ChainId,
CHAIN_ID_AVAX,
CHAIN_ID_BSC,
CHAIN_ID_ETH,
CHAIN_ID_FANTOM,
CHAIN_ID_OASIS,
CHAIN_ID_POLYGON,
MockWETH9__factory,
} from "@certusone/wormhole-sdk";
import {
Container,
ListItemIcon,
makeStyles,
MenuItem,
Paper,
TextField,
Typography,
} from "@material-ui/core";
import { ethers } from "ethers";
import { formatUnits } from "ethers/lib/utils";
import { useCallback, useEffect, useState } from "react";
import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import useIsWalletReady from "../hooks/useIsWalletReady";
import avaxIcon from "../icons/avax.svg";
import bnbIcon from "../icons/bnb.svg";
import ethIcon from "../icons/eth.svg";
import fantomIcon from "../icons/fantom.svg";
import oasisIcon from "../icons/oasis-network-rose-logo.svg";
import polygonIcon from "../icons/polygon.svg";
import { COLORS } from "../muiTheme";
import {
DataWrapper,
errorDataWrapper,
fetchDataWrapper,
getEmptyDataWrapper,
receiveDataWrapper,
} from "../store/helpers";
import {
WAVAX_ADDRESS,
WAVAX_DECIMALS,
WBNB_ADDRESS,
WBNB_DECIMALS,
WETH_ADDRESS,
WETH_DECIMALS,
WFTM_ADDRESS,
WFTM_DECIMALS,
WMATIC_ADDRESS,
WMATIC_DECIMALS,
WROSE_ADDRESS,
WROSE_DECIMALS,
} from "../utils/consts";
import parseError from "../utils/parseError";
import ButtonWithLoader from "./ButtonWithLoader";
import EthereumSignerKey from "./EthereumSignerKey";
import HeaderText from "./HeaderText";
const useStyles = makeStyles((theme) => ({
formControl: {
display: "flex",
margin: `${theme.spacing(1)}px auto`,
width: "100%",
maxWidth: 400,
textAlign: "center",
},
mainPaper: {
backgroundColor: COLORS.whiteWithTransparency,
textAlign: "center",
padding: "2rem",
"& > h, p ": {
margin: ".5rem",
},
},
select: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1),
"& .MuiSelect-root": {
display: "flex",
alignItems: "center",
},
},
listItemIcon: {
minWidth: 40,
},
icon: {
height: 24,
maxWidth: 24,
},
}));
const supportedTokens = {
[CHAIN_ID_ETH]: {
symbol: "WETH",
icon: ethIcon,
address: WETH_ADDRESS,
decimals: WETH_DECIMALS,
},
[CHAIN_ID_BSC]: {
symbol: "WBNB",
icon: bnbIcon,
address: WBNB_ADDRESS,
decimals: WBNB_DECIMALS,
},
[CHAIN_ID_POLYGON]: {
symbol: "WMATIC",
icon: polygonIcon,
address: WMATIC_ADDRESS,
decimals: WMATIC_DECIMALS,
},
[CHAIN_ID_AVAX]: {
symbol: "WAVAX",
icon: avaxIcon,
address: WAVAX_ADDRESS,
decimals: WAVAX_DECIMALS,
},
[CHAIN_ID_OASIS]: {
symbol: "WROSE",
icon: oasisIcon,
address: WROSE_ADDRESS,
decimals: WROSE_DECIMALS,
},
[CHAIN_ID_FANTOM]: {
symbol: "WFTM",
icon: fantomIcon,
address: WFTM_ADDRESS,
decimals: WFTM_DECIMALS,
},
};
interface BalancesInfo {
native: ethers.BigNumber;
wrapped: ethers.BigNumber;
}
function UnwrapNative() {
const classes = useStyles();
const [selectedChainId, setSelectedChainId] = useState<ChainId>(CHAIN_ID_ETH);
const [balances, setBalances] = useState<DataWrapper<BalancesInfo>>(
getEmptyDataWrapper()
);
const [unwrapRequest, setUnwrapRequest] = useState<DataWrapper<boolean>>(
getEmptyDataWrapper()
);
const { signer } = useEthereumProvider();
const { isReady, statusMessage } = useIsWalletReady(selectedChainId);
const handleSelect = useCallback((event) => {
setSelectedChainId(parseInt(event.target.value) as ChainId);
}, []);
useEffect(() => {
setBalances(getEmptyDataWrapper());
setUnwrapRequest(getEmptyDataWrapper());
}, [selectedChainId]);
useEffect(() => {
if (!isReady || !signer) return;
setBalances(fetchDataWrapper());
let cancelled = false;
(async () => {
try {
const native = await signer.getBalance();
if (cancelled) return;
const wrappedToken = await MockWETH9__factory.connect(
supportedTokens[selectedChainId].address,
signer
);
if (cancelled) return;
const signerAddress = await signer.getAddress();
if (cancelled) return;
const wrapped = await wrappedToken.balanceOf(signerAddress);
if (cancelled) return;
setBalances(receiveDataWrapper({ native, wrapped }));
} catch (e) {
console.error(e);
if (cancelled) return;
setBalances(
errorDataWrapper("An error occurred while fetching balances")
);
}
})();
return () => {
cancelled = true;
};
}, [isReady, signer, selectedChainId, unwrapRequest.data]);
const handleClick = useCallback(() => {
if (!isReady || !signer || !balances.data || balances.data.wrapped.eq(0))
return;
const amount = balances.data.wrapped;
let cancelled = false;
setUnwrapRequest(fetchDataWrapper());
(async () => {
try {
const wrappedToken = await MockWETH9__factory.connect(
supportedTokens[selectedChainId].address,
signer
);
const tx = await wrappedToken.withdraw(amount);
await tx.wait();
if (cancelled) return;
setUnwrapRequest(receiveDataWrapper(true));
} catch (e) {
console.error(e);
if (cancelled) return;
setUnwrapRequest(errorDataWrapper(parseError(e)));
}
})();
return () => {
cancelled = true;
};
}, [isReady, signer, selectedChainId, balances.data]);
const error = unwrapRequest.error || balances.error || statusMessage;
return (
<Container maxWidth="md">
<HeaderText white>Unwrap Native Tokens</HeaderText>
<Paper className={classes.mainPaper}>
<Typography style={{ textAlign: "center" }}>
Unwrap (withdraw) native tokens from their wrapped form (e.g. WETH
&rarr; ETH)
</Typography>
<EthereumSignerKey />
<TextField
select
value={selectedChainId}
onChange={handleSelect}
className={classes.select}
disabled={unwrapRequest.isFetching}
>
{Object.entries(supportedTokens).map(([key, item]) => (
<MenuItem key={key} value={key}>
<ListItemIcon className={classes.listItemIcon}>
<img
src={item.icon}
alt={item.symbol}
className={classes.icon}
/>
</ListItemIcon>
{item.symbol}
</MenuItem>
))}
</TextField>
<Typography variant="h5" gutterBottom>
{formatUnits(
balances.data?.wrapped || 0,
supportedTokens[selectedChainId].decimals
)}
</Typography>
<Typography variant="subtitle1" gutterBottom>
{supportedTokens[selectedChainId].symbol.substring(1)}
</Typography>
<Typography variant="h5" gutterBottom>
{formatUnits(
balances.data?.native || 0,
supportedTokens[selectedChainId].decimals
)}
</Typography>
<Typography variant="h5" gutterBottom></Typography>
<ButtonWithLoader
disabled={
!isReady ||
balances.isFetching ||
!balances.data ||
balances.data.wrapped.eq(0) ||
unwrapRequest.isFetching
}
onClick={handleClick}
showLoader={balances.isFetching || unwrapRequest.isFetching}
error={error}
>
Unwrap All
</ButtonWithLoader>
</Paper>
</Container>
);
}
export default UnwrapNative;

View File

@ -29,7 +29,10 @@ import useIsWalletReady from "./useIsWalletReady";
/**
* @param recoveryOnly Only fire when in recovery mode
*/
export default function useGetIsTransferCompleted(recoveryOnly: boolean): {
export default function useGetIsTransferCompleted(
recoveryOnly: boolean,
pollFrequency?: number
): {
isTransferCompletedLoading: boolean;
isTransferCompleted: boolean;
} {
@ -46,6 +49,27 @@ export default function useGetIsTransferCompleted(recoveryOnly: boolean): {
const hasCorrectEvmNetwork = evmChainId === getEvmChainId(targetChain);
const shouldFire = !recoveryOnly || isRecovery;
const [pollState, setPollState] = useState(pollFrequency);
console.log(
"Executing get transfer completed",
isTransferCompleted,
pollState
);
useEffect(() => {
let cancelled = false;
if (pollFrequency && !isLoading && !isTransferCompleted) {
setTimeout(() => {
if (!cancelled) {
setPollState((prevState) => (prevState || 0) + 1);
}
}, pollFrequency);
}
return () => {
cancelled = true;
};
}, [pollFrequency, isLoading, isTransferCompleted]);
useEffect(() => {
if (!shouldFire) {
@ -122,6 +146,7 @@ export default function useGetIsTransferCompleted(recoveryOnly: boolean): {
signedVAA,
isReady,
provider,
pollState,
]);
return { isTransferCompletedLoading: isLoading, isTransferCompleted };

View File

@ -39,6 +39,7 @@ import {
selectTransferIsTargetComplete,
selectTransferOriginAsset,
selectTransferOriginChain,
selectTransferRelayerFee,
selectTransferSourceAsset,
selectTransferSourceChain,
selectTransferSourceParsedTokenAccount,
@ -73,26 +74,39 @@ async function evm(
recipientChain: ChainId,
recipientAddress: Uint8Array,
isNative: boolean,
chainId: ChainId
chainId: ChainId,
relayerFee?: string
) {
dispatch(setIsSending(true));
try {
const amountParsed = parseUnits(amount, decimals);
const baseAmountParsed = parseUnits(amount, decimals);
const feeParsed = parseUnits(relayerFee || "0", decimals);
const transferAmountParsed = baseAmountParsed.add(feeParsed);
console.log(
"base",
baseAmountParsed,
"fee",
feeParsed,
"total",
transferAmountParsed
);
const receipt = isNative
? await transferFromEthNative(
getTokenBridgeAddressForChain(chainId),
signer,
amountParsed,
transferAmountParsed,
recipientChain,
recipientAddress
recipientAddress,
feeParsed
)
: await transferFromEth(
getTokenBridgeAddressForChain(chainId),
signer,
tokenAddress,
amountParsed,
transferAmountParsed,
recipientChain,
recipientAddress
recipientAddress,
feeParsed
);
dispatch(
setTransferTx({ id: receipt.transactionHash, block: receipt.blockNumber })
@ -141,12 +155,15 @@ async function solana(
targetAddress: Uint8Array,
isNative: boolean,
originAddressStr?: string,
originChain?: ChainId
originChain?: ChainId,
relayerFee?: string
) {
dispatch(setIsSending(true));
try {
const connection = new Connection(SOLANA_HOST, "confirmed");
const amountParsed = parseUnits(amount, decimals).toBigInt();
const baseAmountParsed = parseUnits(amount, decimals);
const feeParsed = parseUnits(relayerFee || "0", decimals);
const transferAmountParsed = baseAmountParsed.add(feeParsed);
const originAddress = originAddressStr
? zeroPad(hexToUint8Array(originAddressStr), 32)
: undefined;
@ -156,9 +173,10 @@ async function solana(
SOL_BRIDGE_ADDRESS,
SOL_TOKEN_BRIDGE_ADDRESS,
payerAddress,
amountParsed,
transferAmountParsed.toBigInt(),
targetAddress,
targetChain
targetChain,
feeParsed.toBigInt()
)
: transferFromSolana(
connection,
@ -167,11 +185,13 @@ async function solana(
payerAddress,
fromAddress,
mintAddress,
amountParsed,
transferAmountParsed.toBigInt(),
targetAddress,
targetChain,
originAddress,
originChain
originChain,
undefined,
feeParsed.toBigInt()
);
const transaction = await promise;
const txid = await signSendAndConfirm(wallet, connection, transaction);
@ -218,18 +238,22 @@ async function terra(
decimals: number,
targetChain: ChainId,
targetAddress: Uint8Array,
feeDenom: string
feeDenom: string,
relayerFee?: string
) {
dispatch(setIsSending(true));
try {
const amountParsed = parseUnits(amount, decimals).toString();
const baseAmountParsed = parseUnits(amount, decimals);
const feeParsed = parseUnits(relayerFee || "0", decimals);
const transferAmountParsed = baseAmountParsed.add(feeParsed);
const msgs = await transferFromTerra(
wallet.terraAddress,
TERRA_TOKEN_BRIDGE_ADDRESS,
asset,
amountParsed,
transferAmountParsed.toString(),
targetChain,
targetAddress
targetAddress,
feeParsed.toString()
);
const result = await postWithFees(
@ -293,10 +317,14 @@ export function useHandleTransfer() {
const sourceParsedTokenAccount = useSelector(
selectTransferSourceParsedTokenAccount
);
const relayerFee = useSelector(selectTransferRelayerFee);
console.log("relayerFee", relayerFee);
const sourceTokenPublicKey = sourceParsedTokenAccount?.publicKey;
const decimals = sourceParsedTokenAccount?.decimals;
const isNative = sourceParsedTokenAccount?.isNativeAsset || false;
const disabled = !isTargetComplete || isSending || isSendComplete;
const handleTransferClick = useCallback(() => {
// TODO: we should separate state for transaction vs fetching vaa
if (
@ -316,7 +344,8 @@ export function useHandleTransfer() {
targetChain,
targetAddress,
isNative,
sourceChain
sourceChain,
relayerFee
);
} else if (
sourceChain === CHAIN_ID_SOLANA &&
@ -340,7 +369,8 @@ export function useHandleTransfer() {
targetAddress,
isNative,
originAsset,
originChain
originChain,
relayerFee
);
} else if (
sourceChain === CHAIN_ID_TERRA &&
@ -358,7 +388,8 @@ export function useHandleTransfer() {
decimals,
targetChain,
targetAddress,
terraFeeDenom
terraFeeDenom,
relayerFee
);
} else {
}
@ -367,6 +398,7 @@ export function useHandleTransfer() {
enqueueSnackbar,
sourceChain,
signer,
relayerFee,
solanaWallet,
solPK,
terraWallet,

View File

@ -0,0 +1,335 @@
import {
ChainId,
CHAIN_ID_AVAX,
CHAIN_ID_BSC,
CHAIN_ID_ETH,
CHAIN_ID_FANTOM,
CHAIN_ID_OASIS,
CHAIN_ID_POLYGON,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
} from "@certusone/wormhole-sdk";
import { hexToNativeString } from "@certusone/wormhole-sdk/lib/esm/utils";
import axios from "axios";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { DataWrapper } from "../store/helpers";
import {
selectTransferGasPrice,
selectTransferSourceParsedTokenAccount,
} from "../store/selectors";
import { getCoinGeckoURL, RELAYER_COMPARE_ASSET } from "../utils/consts";
import useRelayersAvailable, { RelayerTokenInfo } from "./useRelayersAvailable";
import { evmEstimatesByContract } from "./useTransactionFees";
export function getRelayAssetInfo(
originChain: ChainId,
originAsset: string,
info: RelayerTokenInfo
) {
if (!originChain || !originAsset || !info) {
return null;
}
return info.supportedTokens?.find(
(x) =>
originAsset.toLowerCase() === x.address?.toLowerCase() &&
originChain === x.chainId
);
}
function isRelayable(
originChain: ChainId,
originAsset: string,
info: RelayerTokenInfo
) {
if (!originChain || !originAsset || !info) {
return false;
}
const tokenRecord = info.supportedTokens?.find(
(x) =>
originAsset.toLowerCase() === x.address?.toLowerCase() &&
originChain === x.chainId
);
return !!(
tokenRecord &&
tokenRecord.address &&
tokenRecord.chainId &&
tokenRecord.coingeckoId
);
}
const ETH_SAFETY_TOLERANCE = 1.25;
export type RelayerInfo = {
isRelayable: boolean;
isRelayingAvailable: boolean;
feeUsd?: string;
feeFormatted?: string;
targetNativeAssetPriceQuote?: number;
};
function calculateFeeUsd(
comparisonAssetPrice: number,
targetChain: ChainId,
gasPrice?: number
) {
let feeUsd = 0;
if (targetChain === CHAIN_ID_SOLANA) {
feeUsd = 2;
} else if (targetChain === CHAIN_ID_ETH) {
if (!gasPrice) {
feeUsd = 0; //catch this error elsewhere
} else {
// Number should be safe as long as we don't modify highGasEstimate to be in the BigInt range
feeUsd =
((Number(evmEstimatesByContract.transfer.highGasEstimate) * gasPrice) /
1000000000) *
comparisonAssetPrice *
ETH_SAFETY_TOLERANCE;
}
} else if (targetChain === CHAIN_ID_TERRA) {
feeUsd = 2;
} else if (targetChain === CHAIN_ID_BSC) {
feeUsd = 2;
} else if (targetChain === CHAIN_ID_POLYGON) {
feeUsd = 0.5;
} else if (targetChain === CHAIN_ID_AVAX) {
feeUsd = 2;
} else if (targetChain === CHAIN_ID_OASIS) {
feeUsd = 0.5;
} else if (targetChain === CHAIN_ID_FANTOM) {
feeUsd = 0.5;
}
return feeUsd;
}
function fixedUsd(fee: number) {
return fee.toFixed(2);
}
function requireGasPrice(targetChain: ChainId) {
return targetChain === CHAIN_ID_ETH;
}
function calculateFeeFormatted(
feeUsd: number,
originAssetPrice: number,
sourceAssetDecimals: number
) {
const sendableDecimals = Math.min(8, sourceAssetDecimals);
const minimumFee = parseFloat(
(10 ** -sendableDecimals).toFixed(sendableDecimals)
);
const calculatedFee = feeUsd / originAssetPrice;
console.log("min", minimumFee, "calc", calculatedFee);
return Math.max(minimumFee, calculatedFee).toFixed(sourceAssetDecimals);
}
//This potentially returns the same chain as the foreign chain, in the case where the asset is native
function useRelayerInfo(
originChain?: ChainId,
originAsset?: string,
targetChain?: ChainId
): DataWrapper<RelayerInfo> {
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [comparisonAssetPrice, setComparisonAssetPrice] = useState<
number | null
>(null);
const [originAssetPrice, setOriginAssetPrice] = useState<number | null>(null);
const sourceParsedTokenAccount = useSelector(
selectTransferSourceParsedTokenAccount
);
const sourceAssetDecimals = sourceParsedTokenAccount?.decimals;
const gasPrice = useSelector(selectTransferGasPrice);
const relayerInfo = useRelayersAvailable(true);
console.log("relayerInfo", relayerInfo);
const originAssetNative =
originAsset && originChain
? hexToNativeString(originAsset, originChain)
: null;
useEffect(() => {
if (
!(originAssetNative && originChain && targetChain && relayerInfo.data)
) {
return;
}
const relayerAsset = getRelayAssetInfo(
originChain,
originAssetNative,
relayerInfo.data
);
//same check as relayable, to satiate typescript.
if (
!(
relayerAsset &&
relayerAsset.address &&
relayerAsset.coingeckoId &&
relayerAsset.chainId
)
) {
return;
}
let cancelled = false;
setIsLoading(true);
setError("");
const promises = [];
const comparisonAsset = RELAYER_COMPARE_ASSET[targetChain];
promises.push(
axios
.get(getCoinGeckoURL(relayerAsset.coingeckoId))
.then((result) => {
if (!cancelled) {
const value = result.data[relayerAsset.coingeckoId as any][
"usd"
] as number;
if (!value) {
setError("Unable to fetch required asset price");
return;
}
setOriginAssetPrice(value);
}
})
.catch((error) => {
if (!cancelled) {
setError("Unable to fetch required asset price.");
}
})
);
promises.push(
axios
.get(getCoinGeckoURL(comparisonAsset))
.then((result) => {
if (!cancelled) {
const value = result.data[comparisonAsset]["usd"] as number;
if (!value) {
setError("Unable to fetch required asset price");
return;
}
setComparisonAssetPrice(value);
}
})
.catch((error) => {
if (!cancelled) {
setError("Unable to fetch required asset price.");
}
})
);
Promise.all(promises).then(() => {
setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [originAssetNative, originChain, targetChain, relayerInfo.data]);
const output: DataWrapper<RelayerInfo> = useMemo(() => {
if (error) {
return {
error: error,
isFetching: false,
receivedAt: null,
data: null,
};
} else if (isLoading || relayerInfo.isFetching) {
return {
error: "",
isFetching: true,
receivedAt: null,
data: null,
};
} else if (relayerInfo.error || !relayerInfo.data) {
return {
error: "",
isFetching: false,
receivedAt: null,
data: {
isRelayable: false,
isRelayingAvailable: false,
targetNativeAssetPriceQuote: undefined, //TODO can still get this without relayers
},
};
} else if (
!originChain ||
!originAssetNative ||
!targetChain ||
!sourceAssetDecimals
) {
return {
error: "Invalid arguments supplied.",
isFetching: false,
receivedAt: null,
data: null,
};
} else if (
!comparisonAssetPrice ||
!originAssetPrice ||
(requireGasPrice(targetChain) && !gasPrice)
) {
return {
error: "Failed to fetch necessary price data.",
isFetching: false,
receivedAt: null,
data: null,
};
} else {
const relayable = isRelayable(
originChain,
originAssetNative,
relayerInfo.data
);
const feeUsd = calculateFeeUsd(
comparisonAssetPrice,
targetChain,
gasPrice
);
const feeFormatted = calculateFeeFormatted(
feeUsd,
originAssetPrice,
sourceAssetDecimals
);
const usdString = fixedUsd(feeUsd);
return {
error: "",
isFetching: false,
receivedAt: null,
data: {
isRelayable: relayable,
isRelayingAvailable: true,
feeUsd: usdString,
feeFormatted: feeFormatted,
targetNativeAssetPriceQuote: comparisonAssetPrice,
},
};
}
}, [
isLoading,
originChain,
targetChain,
error,
comparisonAssetPrice,
originAssetPrice,
gasPrice,
originAssetNative,
relayerInfo.data,
relayerInfo.error,
relayerInfo.isFetching,
sourceAssetDecimals,
]);
return output;
}
export default useRelayerInfo;

View File

@ -0,0 +1,63 @@
import { ChainId } from "@certusone/wormhole-sdk";
import { Dispatch } from "@reduxjs/toolkit";
import axios from "axios";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { DataWrapper } from "../store/helpers";
import { selectRelayerTokenInfo } from "../store/selectors";
import {
errorRelayerTokenInfo,
fetchTerraTokenMap,
receiveRelayerTokenInfo,
} from "../store/tokenSlice";
import { RELAYER_INFO_URL } from "../utils/consts";
export type RelayToken = {
chainId?: ChainId;
address?: string;
coingeckoId?: string;
};
export type Relayer = {
name?: string;
url?: string;
};
export type RelayerTokenInfo = {
supportedTokens?: RelayToken[];
relayers?: Relayer[];
};
const useRelayersAvailable = (
shouldFire: boolean
): DataWrapper<RelayerTokenInfo> => {
const relayerTokenInfo = useSelector(selectRelayerTokenInfo);
console.log("relayerTokenInfo", relayerTokenInfo);
const dispatch = useDispatch();
const internalShouldFire =
shouldFire &&
(relayerTokenInfo.data === undefined ||
(relayerTokenInfo.data === null && !relayerTokenInfo.isFetching));
useEffect(() => {
if (internalShouldFire) {
getRelayersAvailable(dispatch);
}
}, [internalShouldFire, dispatch]);
return relayerTokenInfo;
};
const getRelayersAvailable = (dispatch: Dispatch) => {
dispatch(fetchTerraTokenMap());
axios.get(RELAYER_INFO_URL).then(
(response) => {
dispatch(receiveRelayerTokenInfo(response.data as RelayerTokenInfo));
},
(error) => {
dispatch(
errorRelayerTokenInfo("Failed to retrieve the Terra Token List.")
);
}
);
};
export default useRelayersAvailable;

View File

@ -20,6 +20,8 @@ import { getMultipleAccountsRPC } from "../utils/solana";
import { NATIVE_TERRA_DECIMALS } from "../utils/terra";
import useIsWalletReady from "./useIsWalletReady";
import { LCDClient } from "@terra-money/terra.js";
import { setGasPrice } from "../store/transferSlice";
import { useDispatch } from "react-redux";
export type GasEstimate = {
currentGasPrice: string;
@ -232,19 +234,27 @@ export function useEthereumGasPrice(contract: MethodType, chainId: ChainId) {
const [estimateResults, setEstimateResults] = useState<GasEstimate | null>(
null
);
const dispatch = useDispatch();
useEffect(() => {
if (provider && isReady && !estimateResults) {
getGasEstimates(provider, contract).then(
(results) => {
setEstimateResults(results);
if (results?.currentGasPrice) {
const gasPrice =
(results?.currentGasPrice &&
parseFloat(results.currentGasPrice)) ||
undefined;
dispatch(setGasPrice(gasPrice)); //This is so the relayer hook can pull this from the state rather than remount this hook.
}
},
(error) => {
console.log(error);
}
);
}
}, [provider, isReady, estimateResults, contract]);
}, [provider, isReady, estimateResults, contract, dispatch]);
const results = useMemo(() => estimateResults, [estimateResults]);
return results;
@ -253,14 +263,22 @@ export function useEthereumGasPrice(contract: MethodType, chainId: ChainId) {
function EthGasEstimateSummary({
methodType,
chainId,
priceQuote,
}: {
methodType: MethodType;
chainId: ChainId;
priceQuote?: number;
}) {
const estimate = useEthereumGasPrice(methodType, chainId);
if (!estimate) {
return null;
}
const lowUsd = priceQuote
? (priceQuote * parseFloat(estimate.lowEstimate)).toFixed(2)
: null;
const highUsd = priceQuote
? (priceQuote * parseFloat(estimate.highEstimate)).toFixed(2)
: null;
return (
<Typography
@ -272,14 +290,14 @@ function EthGasEstimateSummary({
flexWrap: "wrap",
}}
>
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", marginRight: 32 }}>
<LocalGasStation fontSize="inherit" />
&nbsp;{estimate.currentGasPrice}
</div>
<div>&nbsp;&nbsp;&nbsp;</div>
<div>
Est. Fees: {estimate.lowEstimate} - {estimate.highEstimate}{" "}
{getDefaultNativeCurrencySymbol(chainId)}
{priceQuote ? <div>{`($${lowUsd} - $${highUsd})`}</div> : null}
</div>
</Typography>
);
@ -292,10 +310,10 @@ const terraEstimatesByContract = {
},
};
const evmEstimatesByContract = {
export const evmEstimatesByContract = {
transfer: {
lowGasEstimate: BigInt(80000),
highGasEstimate: BigInt(130000),
lowGasEstimate: BigInt(250000),
highGasEstimate: BigInt(280000),
},
nft: {
lowGasEstimate: BigInt(350000),
@ -327,7 +345,8 @@ export async function getGasEstimates(
highEstimate = parseFloat(
formatUnits(highEstimateGasAmount * priceInWei.toBigInt(), "ether")
).toFixed(4);
currentGasPrice = parseFloat(formatUnits(priceInWei, "gwei")).toFixed(0);
const gasPriceNum = parseFloat(formatUnits(priceInWei, "gwei"));
currentGasPrice = gasPriceNum.toFixed(0);
}
}
@ -377,12 +396,20 @@ function TerraGasEstimateSummary({ methodType }: { methodType: MethodType }) {
export function GasEstimateSummary({
methodType,
chainId,
priceQuote, //this is a hack, should refactor to unify the fee selector and this file
}: {
methodType: MethodType;
chainId: ChainId;
priceQuote?: number;
}) {
if (isEVMChain(chainId)) {
return <EthGasEstimateSummary chainId={chainId} methodType={methodType} />;
return (
<EthGasEstimateSummary
chainId={chainId}
methodType={methodType}
priceQuote={priceQuote}
/>
);
} else if (chainId === CHAIN_ID_TERRA) {
return <TerraGasEstimateSummary methodType={methodType} />;
} else {

View File

@ -284,6 +284,39 @@ export const selectTransferTargetError = (state: RootState) => {
if (!state.transfer.targetAddressHex) {
return "Target account unavailable";
}
if (state.transfer.useRelayer && state.transfer.relayerFee === undefined) {
return "Invalid relayer fee.";
}
if (state.transfer.relayerFee && state.transfer.sourceParsedTokenAccount) {
try {
// these may trigger error: fractional component exceeds decimals
if (
parseUnits(
state.transfer.amount,
state.transfer.sourceParsedTokenAccount.decimals
)
.add(
parseUnits(
state.transfer.relayerFee.toString(),
state.transfer.sourceParsedTokenAccount.decimals
)
)
.gt(
parseUnits(
state.transfer.sourceParsedTokenAccount.uiAmountString,
state.transfer.sourceParsedTokenAccount.decimals
)
)
) {
return "The amount being transferred plus fees exceeds the wallet's balance.";
}
} catch (e: any) {
if (e?.message) {
return e.message.substring(0, e.message.indexOf("("));
}
return "Invalid amount";
}
}
};
export const selectTransferIsTargetComplete = (state: RootState) =>
!selectTransferTargetError(state);
@ -295,7 +328,12 @@ export const selectTransferShouldLockFields = (state: RootState) =>
selectTransferIsSending(state) || selectTransferIsSendComplete(state);
export const selectTransferIsRecovery = (state: RootState) =>
state.transfer.isRecovery;
export const selectTransferGasPrice = (state: RootState) =>
state.transfer.gasPrice;
export const selectTransferUseRelayer = (state: RootState) =>
state.transfer.useRelayer;
export const selectTransferRelayerFee = (state: RootState) =>
state.transfer.relayerFee;
export const selectSolanaTokenMap = (state: RootState) => {
return state.tokens.solanaTokenMap;
};
@ -311,3 +349,7 @@ export const selectMarketsMap = (state: RootState) => {
export const selectTerraFeeDenom = (state: RootState) => {
return state.fee.terraFeeDenom;
};
export const selectRelayerTokenInfo = (state: RootState) => {
return state.tokens.relayerTokenInfo;
};

View File

@ -1,7 +1,8 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TokenInfo } from "@solana/spl-token-registry";
import { TerraTokenMap } from "../hooks/useTerraTokenMap";
import { MarketsMap } from "../hooks/useMarketsMap";
import { RelayerTokenInfo } from "../hooks/useRelayersAvailable";
import { TerraTokenMap } from "../hooks/useTerraTokenMap";
import {
DataWrapper,
errorDataWrapper,
@ -14,12 +15,14 @@ export interface TokenMetadataState {
solanaTokenMap: DataWrapper<TokenInfo[]>;
terraTokenMap: DataWrapper<TerraTokenMap>; //TODO make a decent type for this.
marketsMap: DataWrapper<MarketsMap>;
relayerTokenInfo: DataWrapper<RelayerTokenInfo>;
}
const initialState: TokenMetadataState = {
solanaTokenMap: getEmptyDataWrapper(),
terraTokenMap: getEmptyDataWrapper(),
marketsMap: getEmptyDataWrapper(),
relayerTokenInfo: getEmptyDataWrapper(),
};
export const tokenSlice = createSlice({
@ -56,6 +59,19 @@ export const tokenSlice = createSlice({
state.marketsMap = errorDataWrapper(action.payload);
},
receiveRelayerTokenInfo: (
state,
action: PayloadAction<RelayerTokenInfo>
) => {
state.relayerTokenInfo = receiveDataWrapper(action.payload);
},
fetchRelayerTokenInfo: (state) => {
state.relayerTokenInfo = fetchDataWrapper();
},
errorRelayerTokenInfo: (state, action: PayloadAction<string>) => {
state.relayerTokenInfo = errorDataWrapper(action.payload);
},
reset: () => initialState,
},
});
@ -70,6 +86,9 @@ export const {
receiveMarketsMap,
fetchMarketsMap,
errorMarketsMap,
receiveRelayerTokenInfo,
fetchRelayerTokenInfo,
errorRelayerTokenInfo,
reset,
} = tokenSlice.actions;

View File

@ -57,6 +57,9 @@ export interface TransferState {
redeemTx: Transaction | undefined;
isApproving: boolean;
isRecovery: boolean;
gasPrice: number | undefined;
useRelayer: boolean;
relayerFee: string | undefined;
}
const initialState: TransferState = {
@ -80,6 +83,9 @@ const initialState: TransferState = {
redeemTx: undefined,
isApproving: false,
isRecovery: false,
gasPrice: undefined,
useRelayer: false,
relayerFee: undefined,
};
export const transferSlice = createSlice({
@ -229,6 +235,7 @@ export const transferSlice = createSlice({
state,
action: PayloadAction<{
vaa: any;
useRelayer: boolean;
parsedPayload: {
targetChain: ChainId;
targetAddress: string;
@ -256,6 +263,16 @@ export const transferSlice = createSlice({
state.amount = action.payload.parsedPayload.amount;
state.activeStep = 3;
state.isRecovery = true;
state.useRelayer = action.payload.useRelayer;
},
setGasPrice: (state, action: PayloadAction<number | undefined>) => {
state.gasPrice = action.payload;
},
setUseRelayer: (state, action: PayloadAction<boolean | undefined>) => {
state.useRelayer = !!action.payload;
},
setRelayerFee: (state, action: PayloadAction<string | undefined>) => {
state.relayerFee = action.payload;
},
},
});
@ -285,6 +302,9 @@ export const {
setIsApproving,
reset,
setRecoveryVaa,
setGasPrice,
setUseRelayer,
setRelayerFee,
} = transferSlice.actions;
export default transferSlice.reducer;

View File

@ -1131,3 +1131,64 @@ export const getIsTransferDisabled = (
? !isSourceChain
: !!disableTransfers;
};
export const LUNA_ADDRESS = "uluna";
export const UST_ADDRESS = "uusd";
export type RelayAsset = {
chain: ChainId;
address: string;
coinGeckoId: string;
};
// export const RELAYER_SUPPORTED_ASSETS: RelayAsset[] =
// CLUSTER === "mainnet"
// ? [{ chain: CHAIN_ID_SOLANA, address: WSOL_ADDRESS, coinGeckoId: "solana" }]
// : CLUSTER === "testnet"
// ? [{ chain: CHAIN_ID_SOLANA, address: WSOL_ADDRESS, coinGeckoId: "solana" }]
// : [
// {
// chain: CHAIN_ID_SOLANA,
// address: WSOL_ADDRESS,
// coinGeckoId: "solana",
// },
// { chain: CHAIN_ID_ETH, address: WETH_ADDRESS, coinGeckoId: "ethereum" },
// {
// chain: CHAIN_ID_TERRA,
// address: LUNA_ADDRESS,
// coinGeckoId: "terra-luna",
// },
// {
// chain: CHAIN_ID_TERRA,
// address: UST_ADDRESS,
// coinGeckoId: "terrausd",
// },
// {
// chain: CHAIN_ID_BSC,
// address: WETH_ADDRESS,
// coinGeckoId: "binancecoin",
// },
// ];
export type RelayerCompareAsset = {
[key in ChainId]: string;
};
export const RELAYER_COMPARE_ASSET: RelayerCompareAsset = {
[CHAIN_ID_SOLANA]: "solana",
[CHAIN_ID_ETH]: "ethereum",
[CHAIN_ID_TERRA]: "terra-luna",
[CHAIN_ID_BSC]: "binancecoin",
[CHAIN_ID_POLYGON]: "matic-network",
[CHAIN_ID_AVAX]: "avalanche-2",
[CHAIN_ID_OASIS]: "oasis-network",
[CHAIN_ID_FANTOM]: "fantom",
} as RelayerCompareAsset;
export const getCoinGeckoURL = (coinGeckoId: string) =>
`https://api.coingecko.com/api/v3/simple/price?ids=${coinGeckoId}&vs_currencies=usd`;
export const RELAYER_INFO_URL =
CLUSTER === "mainnet"
? "https://raw.githubusercontent.com/certusone/wormhole-relayer-list/main/relayer.json"
: CLUSTER === "testnet"
? ""
: "/relayerExample.json";
export const RELAY_URL_EXTENSION = "/relayvaa/";

View File

@ -10,12 +10,12 @@ spec:
selector:
app: algorand
ports:
- name: algod
port: 4001
targetPort: algod
- name: kmd
port: 4002
targetPort: kmd
- name: algod
port: 4001
targetPort: algod
- name: kmd
port: 4002
targetPort: kmd
---
apiVersion: apps/v1
kind: StatefulSet

44
devnet/redis.yaml Normal file
View File

@ -0,0 +1,44 @@
---
apiVersion: v1
kind: Service
metadata:
name: redis
labels:
app: redis
spec:
clusterIP: None
selector:
app: redis
ports:
- port: 6379
name: redis
protocol: TCP
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
spec:
selector:
matchLabels:
app: redis
serviceName: redis
template:
metadata:
labels:
app: redis
spec:
restartPolicy: Always
terminationGracePeriodSeconds: 0
containers:
- name: redis
image: redis
readinessProbe:
tcpSocket:
port: 6379
periodSeconds: 1
failureThreshold: 300
ports:
- containerPort: 6379
name: redis
protocol: TCP

51
devnet/spy-listener.yaml Normal file
View File

@ -0,0 +1,51 @@
---
apiVersion: v1
kind: Service
metadata:
name: spy-listener
labels:
app: spy-listener
spec:
clusterIP: None
selector:
app: spy-listener
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: spy-listener
spec:
selector:
matchLabels:
app: spy-listener
serviceName: spy-listener
replicas: 1
template:
metadata:
labels:
app: spy-listener
spec:
restartPolicy: Always
terminationGracePeriodSeconds: 0
containers:
- name: spy-listener
image: spy-relay-image
command:
- npm
- run
- --prefix
- /app/relayer/spy_relayer/
- tilt_listener
tty: true
readinessProbe:
tcpSocket:
port: 2000
periodSeconds: 1
failureThreshold: 300
ports:
- containerPort: 4201
name: rest
protocol: TCP
- containerPort: 8082
name: prometheus
protocol: TCP

44
devnet/spy-relayer.yaml Normal file
View File

@ -0,0 +1,44 @@
---
apiVersion: v1
kind: Service
metadata:
name: spy-relayer
labels:
app: spy-relayer
spec:
clusterIP: None
selector:
app: spy-relayer
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: spy-relayer
spec:
selector:
matchLabels:
app: spy-relayer
serviceName: spy-relayer
replicas: 1
template:
metadata:
labels:
app: spy-relayer
spec:
restartPolicy: Always
terminationGracePeriodSeconds: 0
containers:
- name: spy-relayer
image: spy-relay-image
command:
- npm
- run
- --prefix
- /app/relayer/spy_relayer/
- tilt_relayer
tty: true
readinessProbe:
tcpSocket:
port: 2000
periodSeconds: 1
failureThreshold: 300

View File

@ -0,0 +1,20 @@
SUPPORTED_CHAINS=[ { "chainId": 1, "chainName": "Solana", "nodeUrl": "http://solana-devnet:8899", "tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16", "bridgeAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o", "walletPrivateKey": [ [ 14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22, 161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164, 152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177, 247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145, 132, 141 ] ], "wrappedAsset": "So11111111111111111111111111111111111111112" }, { "chainId": 2, "chainName": "ETH", "nodeUrl": "http://eth-devnet:8545", "tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16", "walletPrivateKey": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ], "wrappedAsset": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E" }, { "chainId": 3, "chainName": "Terra", "nodeUrl": "http://terra-terrad:1317", "tokenBridgeAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4", "walletPrivateKey": [ "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius" ], "terraName": "localterra", "terraChainId": "columbus-5", "terraCoin": "uluna", "terraGasPriceUrl": "http://terra-fcd:3060/v1/txs/gas_prices" }, { "chainId": 4, "chainName": "BSC", "nodeUrl": "http://eth-devnet2:8545", "tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16", "walletPrivateKey": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ], "wrappedAsset": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E" }]
REDIS_HOST= redis
REDIS_PORT=6379
PROM_PORT=8083
READINESS_PORT=2000
CLEAR_REDIS_ON_INIT=false
DEMOTE_WORKING_ON_INIT=true
LOG_LEVEL=debug
SIMULATED_TERRA_WALLET_ADDRESS=terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v
SUPPORTED_TOKENS=[{"chainId":1,"address":"So11111111111111111111111111111111111111112"}, {"chainId":2,"address":"0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"}, {"chainId":3,"address":"uluna"}, {"chainId":4,"address":"0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"}]
PRIVATE_KEYS=[ { "chainId": 1, "privateKeys": [ [ 14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22, 161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164, 152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177, 247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145, 132, 141 ] ] }, { "chainId": 2, "privateKeys": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ] }, { "chainId": 3, "privateKeys": [ "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius" ] }, { "chainId": 4, "privateKeys": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ] }]
SPY_SERVICE_HOST=spy:7072
SPY_SERVICE_FILTERS=[{"chainId":1,"emitterAddress":"B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE"}, {"chainId":2,"emitterAddress":"0x0290FB167208Af455bB137780163b7B7a9a10C16"}, {"chainId":3,"emitterAddress":"terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4"}, {"chainId":4,"emitterAddress":"0x0290FB167208Af455bB137780163b7B7a9a10C16"}]
SPY_NUM_WORKERS=5

View File

@ -0,0 +1,18 @@
SPY_SERVICE_HOST=spy:7072
#Solana mainnet emitter address Gv1KWf8DT1jKv5pKBmGaTmVszqa56Xn8YGx2Pg7i7qAk
#Devnet emitter address? ENG1wQ7CQKH8ibAJ1hSLmJgL9Ucg6DRDbj752ZAfidLA
#Devnet token bridge address: B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE
SPY_SERVICE_FILTERS=[{"chainId":1,"emitterAddress":"B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE"}, {"chainId":2,"emitterAddress":"0x0290FB167208Af455bB137780163b7B7a9a10C16"}, {"chainId":3,"emitterAddress":"terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4"}, {"chainId":4,"emitterAddress":"0x0290FB167208Af455bB137780163b7B7a9a10C16"}]
SPY_NUM_WORKERS=5
REDIS_HOST=redis
REDIS_PORT=6379
REST_PORT=4201
PROM_PORT=8082
READINESS_PORT=2000
#TODO change this to an array of numbers
#SPY_MIN_FEES = 500000
LOG_LEVEL=debug
SUPPORTED_TOKENS=[{"chainId":1,"address":"So11111111111111111111111111111111111111112"}, {"chainId":2,"address":"0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"}, {"chainId":3,"address":"uluna"}, {"chainId":4,"address":"0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"}]

View File

@ -0,0 +1,11 @@
SUPPORTED_CHAINS=[ { "chainId": 1, "chainName": "Solana", "nativeCurrencySymbol": "SOL", "nodeUrl": "http://solana-devnet:8899", "tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16", "bridgeAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o", "walletPrivateKey": [ [ 14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22, 161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164, 152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177, 247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145, 132, 141 ] ], "wrappedAsset": "So11111111111111111111111111111111111111112" }, { "chainId": 2, "chainName": "Ethereum", "nativeCurrencySymbol": "ETH", "nodeUrl": "http://eth-devnet:8545", "tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16", "walletPrivateKey": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ], "wrappedAsset": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E" }, { "chainId": 3, "chainName": "Terra", "nativeCurrencySymbol": "LUNA", "nodeUrl": "http://terra-terrad:1317", "tokenBridgeAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4", "walletPrivateKey": [ "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius" ], "terraName": "localterra", "terraChainId": "columbus-5", "terraCoin": "uluna", "terraGasPriceUrl": "http://terra-fcd:3060/v1/txs/gas_prices" }, { "chainId": 4, "chainName": "Binance Smart Chain", "nativeCurrencySymbol": "BNB", "nodeUrl": "http://eth-devnet2:8545", "tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16", "walletPrivateKey": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ], "wrappedAsset": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E" }]
REDIS_HOST= redis
REDIS_PORT=6379
PROM_PORT=8083
READINESS_PORT=2000
CLEAR_REDIS_ON_INIT=false
DEMOTE_WORKING_ON_INIT=true
LOG_LEVEL=debug
SIMULATED_TERRA_WALLET_ADDRESS=terra1x46rqay4d3cssq8gxxvqz8xt6nwlz4td20k38v
SUPPORTED_TOKENS=[{"chainId":1,"address":"So11111111111111111111111111111111111111112"}, {"chainId":2,"address":"0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"}, {"chainId":3,"address":"uluna"}, {"chainId":4,"address":"0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"}]
PRIVATE_KEYS=[ { "chainId": 1, "privateKeys": [ [ 14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22, 161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164, 152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177, 247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145, 132, 141 ] ] }, { "chainId": 2, "privateKeys": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ] }, { "chainId": 3, "privateKeys": [ "notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius" ] }, { "chainId": 4, "privateKeys": [ "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" ] }]

2
relayer/spy_relayer/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/lib
*.log

View File

@ -0,0 +1,24 @@
# syntax=docker.io/docker/dockerfile:1.3@sha256:42399d4635eddd7a9b8a24be879d2f9a930d0ed040a61324cfdf59ef1357b3b2
FROM node:lts-alpine3.15@sha256:a2c7f8ebdec79619fba306cec38150db44a45b48380d09603d3602139c5a5f92
RUN mkdir -p /app
WORKDIR /app
RUN apk add python3 \
make \
g++
ADD . .
RUN echo $(ls -1 .)
RUN echo $(less Dockerfile)
WORKDIR ./relayer/spy_relayer
RUN npm ci && \
npm run build
#TODO don't hardcode for tilt but accept env file
# RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
# npm run tilt_relay

View File

@ -0,0 +1,3 @@
Test Scenarios:
- Should survive any container restarting: Spy, Listener, Redis, or Relayer.

View File

@ -0,0 +1,71 @@
In order to compile spy_relay you need to do:
```
npm install redis
```
In order to run spy_relay successfully you need to do:
```
docker pull redis
```
The above will grab the docker for redis.
In order to run that docker use a command similar to:
```
docker run --rm -p6379:6379 --name redis-docker -d redis
```
To run the redis GUI do the following:
```
sudo apt-get install snapd
sudo snap install redis-desktop-manager
cd /var/lib/snapd/desktop/applications; ./redis-desktop-manager_rdm.desktop
```
To build the spy / guardian docker container:
```
cd spy_relay
docker build -f Dockerfile -t guardian .
```
To run the docker image in TestNet:
```
docker run -e ARGS='--spyRPC [::]:7073 --network /wormhole/testnet/2/1 --bootstrap /dns4/wormhole-testnet-v2-bootstrap.certus.one/udp/8999/quic/p2p/12D3KooWBY9ty9CXLBXGQzMuqkziLntsVcyz4pk1zWaJRvJn6Mmt' -p 7073:7073 guardian
```
To run spy_relay:
```
npm run spy_relay
```
## Spy Listener Environment variables
see .env.tilt.listener for an example
- SPY_SERVICE_HOST - host & port string to connect to the spy
- SPY_SERVICE_FILTERS - Addresses to monitor (Bridge contract addresses) array of ["chainId","emitterAddress"]. Emitter addresses are native strings.
- REDIS_HOST - ip / host for the REDIS instance.
- REDIS_PORT - port number for redis.
- REST_PORT - port that the REST entrypoint will listen on.
- READINESS_PORT - port for kubernetes readiness probe
- LOG_LEVEL - log level, such as debug
- SUPPORTED_TOKENS - Origin assets that will attempt to be relayed. Array of ["chainId","address"], address should be a native string.
## Spy Relayer Environment variables
see .env.tilt.relayer for an example
- SUPPORTED_CHAINS - The configuration for each chain which will be relayed. See chainConfigs.example.json for the format. Of note, walletPrivateKey is an array, and a separate worker will be spun up for every private key provided.
- REDIS_HOST - host of the redis service, should be the same as in the spy_listener
- REDIS_PORT - port for redis to connect to
- PROM_PORT - port where prometheus monitoring will listen
- READINESS_PORT - port for kubernetes readiness probe
- CLEAR_REDIS_ON_INIT - boolean, if TRUE the relayer will clear the PENDING and WORKING Redis tables before it starts up.
- DEMOTE_WORKING_ON_INIT - boolean, if TRUE the relayer will move everything from the WORKING Redis table to the PENDING one.
- LOG_LEVEL - log level, debug or info

View File

@ -0,0 +1,13 @@
SPY_SERVICE_HOST= change me (spyhost:port)
SPY_SERVICE_FILTERS= paste from emitterAddresses.json
SPY_NUM_WORKERS=5
REDIS_HOST= change me
REDIS_PORT= change me
REST_PORT=4201
PROM_PORT=8082
READINESS_PORT=2000
LOG_LEVEL=debug
SUPPORTED_TOKENS= paste from supportedTokens.json

View File

@ -0,0 +1,11 @@
SUPPORTED_CHAINS= paste from supportedChains.json
REDIS_HOST= change me
REDIS_PORT= change me
PROM_PORT=8083
READINESS_PORT=2000
CLEAR_REDIS_ON_INIT=false
DEMOTE_WORKING_ON_INIT=true
LOG_LEVEL=debug
SIMULATED_TERRA_WALLET_ADDRESS= Requires a terra public address which will always have enough funds to pay for transactions. one of the hot wallets will do.
SUPPORTED_TOKENS= paste from supportedTokens.json. This must be the same as in the .env.listener file.
PRIVATE_KEYS= paste from privateKeys.json

View File

@ -0,0 +1,39 @@
To utilize:
- Alter the information supportedChains.json marked as CHANGE ME.
- Modify supportedTokens.json as needed
- Modify privateKeys.json as needed
- Remove newlines from supportedChains.json
- Remove newlines from emitterAddress.json
- Remove newlines from supportedTokens.json
- Remove newlines from privateKeys.json
- Add the required information to .env.listener & .env.relayer
Useful addresses:
WSOL:
So11111111111111111111111111111111111111112
WETH:
0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
UST:
uusd
LUNA:
uluna
WBNB:
0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c
WMATIC:
0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270
WAVAX:
0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7
WROSE:
0x21C718C22D52d0F3a789b752D4c2fD5908a8A733
WFTM:
0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83

View File

@ -0,0 +1,10 @@
[
{"chainId":1,"emitterAddress":"wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb"},
{"chainId":2,"emitterAddress":"0x3ee18B2214AFF97000D974cf647E7C347E8fa585"},
{"chainId":3,"emitterAddress":"terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf"},
{"chainId":4,"emitterAddress":"0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7"},
{"chainId":5,"emitterAddress":"0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE"},
{"chainId":6,"emitterAddress":"0x0e082F06FF657D94310cB8cE8B0D9a04541d8052"},
{"chainId":7,"emitterAddress":"0x5848C791e09901b40A9Ef749f2a6735b418d7564"},
{"chainId":10,"emitterAddress":"0x7C9Fc5741288cDFdD83CeB07f3ea7e22618D79D2"}
]

View File

@ -0,0 +1,32 @@
[
{
"chainId": 1,
"privateKeys": [
[
"change", "me", 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22,
161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164,
152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177,
247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145,
132, 141
]
]
},
{
"chainId": 2,
"privateKeys": [
"0xCHANGEMEac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
]
},
{
"chainId": 3,
"privateKeys": [
"CHANGE ME worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
]
},
{
"chainId": 4,
"privateKeys": [
"0xCHANGEME3ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
]
}
]

View File

@ -0,0 +1,70 @@
[
{
"chainId": 1,
"chainName": "Solana",
"nativeCurrencySymbol": "SOL",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb",
"bridgeAddress": "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth",
"wrappedAsset": "So11111111111111111111111111111111111111112"
},
{
"chainId": 2,
"chainName": "Ethereum",
"nativeCurrencySymbol": "ETH",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "0x3ee18B2214AFF97000D974cf647E7C347E8fa585",
"wrappedAsset": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
},
{
"chainId": 3,
"chainName": "Terra",
"nativeCurrencySymbol": "LUNA",
"nodeUrl": "https://fcd.terra.dev OR SOMETHING ELSE... ALSO UPDATE GAS PRICE URL",
"tokenBridgeAddress": "terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf",
"terraName": "mainnet",
"terraChainId": "columbus-5",
"terraCoin": "uluna",
"terraGasPriceUrl": "https://fcd.terra.dev/v1/txs/gas_prices <- SHOULD BE SAME AS NODE URL"
},
{
"chainId": 4,
"chainName": "Binance Smart Chain",
"nativeCurrencySymbol": "BNB",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7",
"wrappedAsset": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"
},
{
"chainId": 5,
"chainName": "Polygon",
"nativeCurrencySymbol": "MATIC",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE",
"wrappedAsset": "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270"
},
{
"chainId": 6,
"chainName": "Avalanche",
"nativeCurrencySymbol": "AVAX",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "0x0e082F06FF657D94310cB8cE8B0D9a04541d8052",
"wrappedAsset": "0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7"
},
{
"chainId": 7,
"chainName": "Oasis",
"nativeCurrencySymbol": "ROSE",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "0x5848C791e09901b40A9Ef749f2a6735b418d7564",
"wrappedAsset": "0x21C718C22D52d0F3a789b752D4c2fD5908a8A733"
},
{
"chainId": 10,
"chainName": "Fantom",
"nativeCurrencySymbol": "FTM",
"nodeUrl": "CHANGE ME",
"tokenBridgeAddress": "0x7C9Fc5741288cDFdD83CeB07f3ea7e22618D79D2",
"wrappedAsset": "0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83"
}
]

View File

@ -0,0 +1,15 @@
[
{"chainId":1,"address":"So11111111111111111111111111111111111111112"},
{"chainId":2,"address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"},
{"chainId":3,"address":"uluna"},
{"chainId":3,"address":"uusd"},
{"chainId":4,"address":"0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"},
{"chainId":5,"address":"0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270"},
{"chainId":6,"address":"0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7"},
{"chainId":7,"address":"0x21C718C22D52d0F3a789b752D4c2fD5908a8A733"},
{"chainId":10,"address":"0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83"},
{"chainId":2,"address":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"},
{"chainId":2,"address":"0xdac17f958d2ee523a2206206994597c13d831ec7"}
]

View File

@ -0,0 +1,47 @@
## Docker Images
VAA_Listener
Redis
Relayer
## Dependencies
- Guardian Spy
- Blockchain Nodes for all supported target chains
## High Level Workflow:
The VAA_Listener listens for Token Bridge SignedVAAs coming from both the guardian network (via a guardian spy), and end users (via a REST interface).
The VAA_Listener then passes this SignedVAA into a validate function, which determines if this VAA should be processed. If so, it enqueues the VAA in redis to be processed by the relayer.
Validation criteria:
- VAA must be token bridge signedVAA of type payload 1.
- VAA be for a supported target chain & origin asset.
- VAA must have a sufficiently high 'fee' field on it.
- VAA must not already be in the 'incoming', 'in-work', or 'pending confirmation' redis tables. (Optionally, also a max-retries exceeded table?)
- VAA must not be already redeemed.
# Redis
Four tables:
- Incoming: These are requests which have been queued by the listener, but have not yet been attempted by the relayer.
- In-Work: These are requests which have been popped off the 'incoming' stack, but have not yet been successfuly submitted on chain.
- Pending Confirmation: These are requests which have been successfully submitted on chain, and are waiting for a finality check to ensure they were not rolled back.
- Failed: These are requests which were removed from the In-Work table due to having exceeded their max number of retries.
All requests enter via the 'Incoming' table, and should eventually either be 'purged' once they successfully exit the Pending Confirmation table, or end in the "Failed" table. For data retention purposes, it may be worthwhile to have a "Completed" table, however, logging should be sufficient for this.
# Relayer
The relayer is responsible for monitoring redis and submitting transaction on chain.
The relayer spawns a worker for each combination of {targetChain + privateKey}, such that no two schedulers should collide on-chain.
Each worker perpetually attempts to submit items in the 'In-Work' table which are assigned to them. When they successfully process an In-Work item, they move it to the Pending Confirmation table. If they are not successful, they increment the failure-count on the In-Work item. If the failure-count exceeds MAX_RETRIES, the In-Work item is moved to the 'Failed' table.
If there are no eligible items in the In-Work table, the worker will scan the Incoming table, and move the Incoming item into the 'In-Work' table under their name. Workers are identified by a string which is their target chain + the public key of their wallet.
Prior to submitting a signedVAA, relayers should check that the VAA has not been redeemed, as other processes may 'scoop' a VAA.

View File

@ -0,0 +1,8 @@
{
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"],
"transformIgnorePatterns": ["/node_modules/"]
}

13307
relayer/spy_relayer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
{
"name": "spy_relay",
"version": "1.0.0",
"description": "Spy listener and relayer",
"main": "spy_relay.js",
"scripts": {
"build": "tsc",
"spy_relay": "node lib/main.js",
"tilt_listener": "SPY_RELAY_CONFIG=.env.tilt.listener node lib/main.js --listen_only",
"tilt_relayer": "SPY_RELAY_CONFIG=.env.tilt.relayer node lib/main.js --relay_only",
"listen_only": "node lib/main.js --listen_only",
"relay_only": "node lib/main.js --relay_only",
"test": "jest --config jestconfig.json --verbose"
},
"author": "",
"license": "Apache-2.0",
"devDependencies": {
"@improbable-eng/grpc-web-node-http-transport": "^0.15.0",
"@types/jest": "^27.0.2",
"@types/long": "^4.0.1",
"@types/node": "^16.6.1",
"axios": "^0.24.0",
"esm": "^3.2.25",
"ethers": "5.4.4",
"jest": "^27.3.1",
"prettier": "^2.3.2",
"ts-jest": "^27.0.7",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typescript": "^4.3.5"
},
"dependencies": {
"@certusone/wormhole-sdk": "^0.2.3",
"@certusone/wormhole-spydk": "^0.0.1",
"@solana/spl-token": "^0.1.8",
"@solana/web3.js": "^1.24.0",
"@terra-money/wallet-provider": "^3.8.0",
"@types/express": "^4.17.13",
"async-mutex": "^0.3.2",
"body-parser": "^1.19.0",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"prom-client": "^14.0.1",
"redis": "^4.0.1",
"winston": "^3.3.3"
}
}

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "@jest/globals";
import { Connection, PublicKey } from "@solana/web3.js";
// see devnet.md
export const ETH_NODE_URL = "ws://localhost:8545";
export const ETH_PRIVATE_KEY =
"0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d";
export const ETH_PUBLIC_KEY = "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1";
export const ETH_CORE_BRIDGE_ADDRESS =
"0xC89Ce4735882C9F0f0FE26686c53074E09B0D550";
export const ETH_TOKEN_BRIDGE_ADDRESS =
"0x0290FB167208Af455bB137780163b7B7a9a10C16";
export const SOLANA_HOST = "http://localhost:8899";
export const SOLANA_PRIVATE_KEY = new Uint8Array([
14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22, 161, 89,
84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164, 152, 70, 87, 65,
8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177, 247, 77, 19, 112, 47,
44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145, 132, 141,
]);
export const SOLANA_CORE_BRIDGE_ADDRESS =
"Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o";
export const SOLANA_TOKEN_BRIDGE_ADDRESS =
"B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE";
export const TERRA_NODE_URL = "http://localhost:1317";
export const TERRA_CHAIN_ID = "localterra";
export const TERRA_GAS_PRICES_URL = "http://localhost:3060/v1/txs/gas_prices";
export const TERRA_CORE_BRIDGE_ADDRESS =
"terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5";
export const TERRA_TOKEN_BRIDGE_ADDRESS =
"terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4";
export const TERRA_PRIVATE_KEY =
"notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius";
export const TEST_ERC20 = "0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A";
export const TEST_SOLANA_TOKEN = "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ";
export const WORMHOLE_RPC_HOSTS = ["http://localhost:7071"];
export const SPY_RELAY_URL = "http://localhost:4200";
describe("consts should exist", () => {
it("has Solana test token", () => {
expect.assertions(1);
const connection = new Connection(SOLANA_HOST, "confirmed");
return expect(
connection.getAccountInfo(new PublicKey(TEST_SOLANA_TOKEN))
).resolves.toBeTruthy();
});
});

View File

@ -0,0 +1,799 @@
import {
approveEth,
attestFromEth,
attestFromSolana,
CHAIN_ID_ETH,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
createWrappedOnEth,
createWrappedOnSolana,
createWrappedOnTerra,
getEmitterAddressEth,
getEmitterAddressSolana,
getForeignAssetSolana,
getIsTransferCompletedEth,
getIsTransferCompletedSolana,
getIsTransferCompletedTerra,
hexToUint8Array,
nativeToHexString,
postVaaSolana,
parseSequenceFromLogEth,
parseSequenceFromLogSolana,
redeemOnSolana,
transferFromEth,
transferFromSolana,
uint8ArrayToHex,
} from "@certusone/wormhole-sdk";
import getSignedVAAWithRetry from "@certusone/wormhole-sdk/lib/cjs/rpc/getSignedVAAWithRetry";
import { setDefaultWasm } from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
import { parseUnits } from "@ethersproject/units";
import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport";
import { describe, expect, jest, test } from "@jest/globals";
import { ethers } from "ethers";
import {
ASSOCIATED_TOKEN_PROGRAM_ID,
Token,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
import { LCDClient, MnemonicKey } from "@terra-money/terra.js";
import axios from "axios";
import {
ETH_CORE_BRIDGE_ADDRESS,
ETH_NODE_URL,
ETH_PRIVATE_KEY,
ETH_PUBLIC_KEY,
ETH_TOKEN_BRIDGE_ADDRESS,
SOLANA_CORE_BRIDGE_ADDRESS,
SOLANA_HOST,
SOLANA_PRIVATE_KEY,
SOLANA_TOKEN_BRIDGE_ADDRESS,
SPY_RELAY_URL,
TERRA_CHAIN_ID,
TERRA_GAS_PRICES_URL,
TERRA_NODE_URL,
TERRA_PRIVATE_KEY,
TERRA_TOKEN_BRIDGE_ADDRESS,
TEST_ERC20,
TEST_SOLANA_TOKEN,
WORMHOLE_RPC_HOSTS,
} from "./consts";
import { sleep } from "../helpers/utils";
setDefaultWasm("node");
jest.setTimeout(60000);
test("Verify Spy Relay is running", (done) => {
(async () => {
try {
console.log(
"Sending query to spy relay to see if it's running, query: [%s]",
SPY_RELAY_URL
);
const result = await axios.get(SPY_RELAY_URL);
expect(result).toHaveProperty("status");
expect(result.status).toBe(200);
done();
} catch (e) {
console.error("Spy Relay does not appear to be running!");
console.error(e);
done("Spy Relay does not appear to be running!");
}
})();
});
var sequence: string;
var emitterAddress: string;
var transferSignedVAA: Uint8Array;
describe("Solana to Ethereum", () => {
test("Attest Solana SPL to Ethereum", (done) => {
(async () => {
console.log("Attest Solana SPL to Ethereum");
try {
// create a keypair for Solana
const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
const payerAddress = keypair.publicKey.toString();
// attest the test token
const connection = new Connection(SOLANA_HOST, "confirmed");
const transaction = await attestFromSolana(
connection,
SOLANA_CORE_BRIDGE_ADDRESS,
SOLANA_TOKEN_BRIDGE_ADDRESS,
payerAddress,
TEST_SOLANA_TOKEN
);
// sign, send, and confirm transaction
transaction.partialSign(keypair);
const txid = await connection.sendRawTransaction(
transaction.serialize()
);
await connection.confirmTransaction(txid);
const info = await connection.getTransaction(txid);
if (!info) {
throw new Error(
"An error occurred while fetching the transaction info"
);
}
// get the sequence from the logs (needed to fetch the vaa)
const sequence = parseSequenceFromLogSolana(info);
emitterAddress = await getEmitterAddressSolana(
SOLANA_TOKEN_BRIDGE_ADDRESS
);
// poll until the guardian(s) witness and sign the vaa
const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS,
CHAIN_ID_SOLANA,
emitterAddress,
sequence,
{
transport: NodeHttpTransport(),
}
);
// create a signer for Eth
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
try {
await createWrappedOnEth(ETH_TOKEN_BRIDGE_ADDRESS, signer, signedVAA);
} catch (e) {
// this could fail because the token is already attested (in an unclean env)
}
provider.destroy();
done();
} catch (e) {
console.error(e);
done(
"An error occurred while trying to attest from Solana to Ethereum"
);
}
})();
});
// TODO: it is attested
test("Send Solana SPL to Ethereum", (done) => {
(async () => {
console.log("Send Solana SPL to Ethereum");
try {
// create a signer for Eth
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
const targetAddress = await signer.getAddress();
// create a keypair for Solana
const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
const payerAddress = keypair.publicKey.toString();
// find the associated token account
const fromAddress = (
await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
new PublicKey(TEST_SOLANA_TOKEN),
keypair.publicKey
)
).toString();
// transfer the test token
const connection = new Connection(SOLANA_HOST, "confirmed");
const amount = parseUnits("1", 9).toBigInt();
const transaction = await transferFromSolana(
connection,
SOLANA_CORE_BRIDGE_ADDRESS,
SOLANA_TOKEN_BRIDGE_ADDRESS,
payerAddress,
fromAddress,
TEST_SOLANA_TOKEN,
amount,
hexToUint8Array(nativeToHexString(targetAddress, CHAIN_ID_ETH) || ""),
CHAIN_ID_ETH
);
// sign, send, and confirm transaction
console.log("Sending transaction.");
transaction.partialSign(keypair);
const txid = await connection.sendRawTransaction(
transaction.serialize()
);
console.log("Confirming transaction.");
await connection.confirmTransaction(txid);
const info = await connection.getTransaction(txid);
if (!info) {
throw new Error(
"An error occurred while fetching the transaction info"
);
}
// get the sequence from the logs (needed to fetch the vaa)
console.log("Parsing sequence number from log.");
sequence = parseSequenceFromLogSolana(info);
const emitterAddress = await getEmitterAddressSolana(
SOLANA_TOKEN_BRIDGE_ADDRESS
);
// poll until the guardian(s) witness and sign the vaa
console.log("Waiting on signed vaa, sequence %d", sequence);
const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS,
CHAIN_ID_SOLANA,
emitterAddress,
sequence,
{
transport: NodeHttpTransport(),
}
);
console.log("Got signed vaa: ", signedVAA);
transferSignedVAA = signedVAA;
provider.destroy();
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to send from Solana to Ethereum");
}
})();
});
test("Spy Relay redeemed on Eth", (done) => {
(async () => {
try {
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
var success: boolean = false;
for (let count = 0; count < 5 && !success; ++count) {
console.log(
"sleeping before querying spy relay",
new Date().toLocaleString()
);
await sleep(5000);
success = await getIsTransferCompletedEth(
ETH_TOKEN_BRIDGE_ADDRESS,
provider,
transferSignedVAA
);
console.log(
"getIsTransferCompletedEth returned %d, count is %d",
success,
count
);
}
expect(success).toBe(true);
provider.destroy();
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to redeem on Eth");
}
})();
});
test("Query Spy Relay via REST", (done) => {
(async () => {
var storeKey: string =
CHAIN_ID_SOLANA.toString() +
"/" +
emitterAddress +
"/" +
sequence.toString();
try {
var query: string = SPY_RELAY_URL + "/query/" + storeKey;
console.log("Sending query to spy relay, query: [%s]", query);
const result = await axios.get(query);
console.log(
"status: ",
result.status,
", statusText: ",
result.statusText,
", data: ",
result.data
);
expect(result).toHaveProperty("status");
expect(result.status).toBe(200);
expect(result).toHaveProperty("data");
expect(JSON.parse(result.data).vaa_bytes).toBe(
uint8ArrayToHex(transferSignedVAA)
);
console.log(result.data);
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to send query to spy relay");
}
})();
});
});
describe("Ethereum to Solana", () => {
test("Attest Ethereum ERC-20 to Solana", (done) => {
(async () => {
try {
// create a signer for Eth
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
// attest the test token
const receipt = await attestFromEth(
ETH_TOKEN_BRIDGE_ADDRESS,
signer,
TEST_ERC20
);
// get the sequence from the logs (needed to fetch the vaa)
const sequence = parseSequenceFromLogEth(
receipt,
ETH_CORE_BRIDGE_ADDRESS
);
const emitterAddress = getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS);
// poll until the guardian(s) witness and sign the vaa
const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS,
CHAIN_ID_ETH,
emitterAddress,
sequence,
{
transport: NodeHttpTransport(),
}
);
// create a keypair for Solana
const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
const payerAddress = keypair.publicKey.toString();
// post vaa to Solana
const connection = new Connection(SOLANA_HOST, "confirmed");
await postVaaSolana(
connection,
async (transaction) => {
transaction.partialSign(keypair);
return transaction;
},
SOLANA_CORE_BRIDGE_ADDRESS,
payerAddress,
Buffer.from(signedVAA)
);
// create wormhole wrapped token (mint and metadata) on solana
const transaction = await createWrappedOnSolana(
connection,
SOLANA_CORE_BRIDGE_ADDRESS,
SOLANA_TOKEN_BRIDGE_ADDRESS,
payerAddress,
signedVAA
);
// sign, send, and confirm transaction
try {
transaction.partialSign(keypair);
const txid = await connection.sendRawTransaction(
transaction.serialize()
);
await connection.confirmTransaction(txid);
} catch (e) {
// this could fail because the token is already attested (in an unclean env)
}
provider.destroy();
done();
} catch (e) {
console.error(e);
done(
"An error occurred while trying to attest from Ethereum to Solana"
);
}
})();
});
// TODO: it is attested
test("Send Ethereum ERC-20 to Solana", (done) => {
(async () => {
try {
// create a keypair for Solana
const connection = new Connection(SOLANA_HOST, "confirmed");
const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
const payerAddress = keypair.publicKey.toString();
// determine destination address - an associated token account
const solanaMintKey = new PublicKey(
(await getForeignAssetSolana(
connection,
SOLANA_TOKEN_BRIDGE_ADDRESS,
CHAIN_ID_ETH,
hexToUint8Array(nativeToHexString(TEST_ERC20, CHAIN_ID_ETH) || "")
)) || ""
);
const recipient = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
solanaMintKey,
keypair.publicKey
);
// create the associated token account if it doesn't exist
const associatedAddressInfo = await connection.getAccountInfo(
recipient
);
if (!associatedAddressInfo) {
const transaction = new Transaction().add(
await Token.createAssociatedTokenAccountInstruction(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
solanaMintKey,
recipient,
keypair.publicKey, // owner
keypair.publicKey // payer
)
);
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = keypair.publicKey;
// sign, send, and confirm transaction
transaction.partialSign(keypair);
const txid = await connection.sendRawTransaction(
transaction.serialize()
);
await connection.confirmTransaction(txid);
}
// create a signer for Eth
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
const amount = parseUnits("1", 18);
// approve the bridge to spend tokens
await approveEth(ETH_TOKEN_BRIDGE_ADDRESS, TEST_ERC20, signer, amount);
// transfer tokens
const receipt = await transferFromEth(
ETH_TOKEN_BRIDGE_ADDRESS,
signer,
TEST_ERC20,
amount,
CHAIN_ID_SOLANA,
hexToUint8Array(
nativeToHexString(recipient.toString(), CHAIN_ID_SOLANA) || ""
)
);
// get the sequence from the logs (needed to fetch the vaa)
sequence = parseSequenceFromLogEth(receipt, ETH_CORE_BRIDGE_ADDRESS);
emitterAddress = getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS);
// poll until the guardian(s) witness and sign the vaa
const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS,
CHAIN_ID_ETH,
emitterAddress,
sequence,
{
transport: NodeHttpTransport(),
}
);
console.log("Got signed vaa: ", signedVAA);
transferSignedVAA = signedVAA;
// post vaa to Solana
// await postVaaSolana( // I think this is the redeem!
// connection,
// async (transaction) => {
// transaction.partialSign(keypair);
// return transaction;
// },
// SOLANA_CORE_BRIDGE_ADDRESS,
// payerAddress,
// Buffer.from(signedVAA)
// );
provider.destroy();
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to send from Ethereum to Solana");
}
})();
});
test("Spy Relay redeemed on Sol", (done) => {
(async () => {
try {
const connection = new Connection(SOLANA_HOST, "confirmed");
var success: boolean = false;
for (let count = 0; count < 5 && !success; ++count) {
console.log(
"sleeping before querying spy relay",
new Date().toLocaleString()
);
await sleep(5000);
success = await getIsTransferCompletedSolana(
SOLANA_TOKEN_BRIDGE_ADDRESS,
transferSignedVAA,
connection
);
console.log(
"getIsTransferCompletedSolana returned %d, count is %d",
success,
count
);
}
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to redeem on Sol");
}
})();
});
test("Query Spy Relay via REST", (done) => {
(async () => {
var storeKey: string =
CHAIN_ID_ETH.toString() +
"/" +
emitterAddress +
"/" +
sequence.toString();
try {
var query: string = SPY_RELAY_URL + "/query/" + storeKey;
console.log("Sending query to spy relay, query: [%s]", query);
const result = await axios.get(query);
console.log(
"status: ",
result.status,
", statusText: ",
result.statusText,
", data: ",
result.data
);
expect(result).toHaveProperty("status");
expect(result.status).toBe(200);
expect(result).toHaveProperty("data");
expect(JSON.parse(result.data).vaa_bytes).toBe(
uint8ArrayToHex(transferSignedVAA)
);
console.log(result.data);
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to send query to spy relay");
}
})();
});
});
describe("Ethereum to Terra", () => {
test("Attest Ethereum ERC-20 to Terra", (done) => {
(async () => {
try {
// create a signer for Eth
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
// attest the test token
const receipt = await attestFromEth(
ETH_TOKEN_BRIDGE_ADDRESS,
signer,
TEST_ERC20
);
// get the sequence from the logs (needed to fetch the vaa)
const sequence = parseSequenceFromLogEth(
receipt,
ETH_CORE_BRIDGE_ADDRESS
);
const emitterAddress = getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS);
// poll until the guardian(s) witness and sign the vaa
const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS,
CHAIN_ID_ETH,
emitterAddress,
sequence,
{
transport: NodeHttpTransport(),
}
);
const lcd = new LCDClient({
URL: TERRA_NODE_URL,
chainID: TERRA_CHAIN_ID,
});
const mk = new MnemonicKey({
mnemonic: TERRA_PRIVATE_KEY,
});
const wallet = lcd.wallet(mk);
const msg = await createWrappedOnTerra(
TERRA_TOKEN_BRIDGE_ADDRESS,
wallet.key.accAddress,
signedVAA
);
const gasPrices = await axios
.get(TERRA_GAS_PRICES_URL)
.then((result) => result.data);
const account = await lcd.auth.accountInfo(wallet.key.accAddress);
const feeEstimate = await lcd.tx.estimateFee(
[
{
sequenceNumber: account.getSequenceNumber(),
publicKey: account.getPublicKey(),
},
],
{
msgs: [msg],
feeDenoms: ["uluna"],
gasPrices,
}
);
const tx = await wallet.createAndSignTx({
msgs: [msg],
memo: "test",
feeDenoms: ["uluna"],
gasPrices,
fee: feeEstimate,
});
await lcd.tx.broadcast(tx);
provider.destroy();
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to attest from Ethereum to Terra");
}
})();
});
// TODO: it is attested
test("Send Ethereum ERC-20 to Terra", (done) => {
(async () => {
try {
// create a signer for Eth
const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
const amount = parseUnits("1", 18);
// approve the bridge to spend tokens
await approveEth(ETH_TOKEN_BRIDGE_ADDRESS, TEST_ERC20, signer, amount);
const lcd = new LCDClient({
URL: TERRA_NODE_URL,
chainID: TERRA_CHAIN_ID,
});
const mk = new MnemonicKey({
mnemonic: TERRA_PRIVATE_KEY,
});
const wallet = lcd.wallet(mk);
// transfer tokens
const receipt = await transferFromEth(
ETH_TOKEN_BRIDGE_ADDRESS,
signer,
TEST_ERC20,
amount,
CHAIN_ID_TERRA,
hexToUint8Array(
nativeToHexString(wallet.key.accAddress, CHAIN_ID_TERRA) || ""
)
);
// get the sequence from the logs (needed to fetch the vaa)
sequence = parseSequenceFromLogEth(receipt, ETH_CORE_BRIDGE_ADDRESS);
emitterAddress = getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS);
// poll until the guardian(s) witness and sign the vaa
const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
WORMHOLE_RPC_HOSTS,
CHAIN_ID_ETH,
emitterAddress,
sequence,
{
transport: NodeHttpTransport(),
}
);
console.log("Got signed vaa: ", signedVAA);
transferSignedVAA = signedVAA;
// expect(
// await getIsTransferCompletedTerra(
// TERRA_TOKEN_BRIDGE_ADDRESS,
// signedVAA,
// wallet.key.accAddress,
// lcd,
// TERRA_GAS_PRICES_URL
// )
// ).toBe(false);
// const msg = await redeemOnTerra(
// TERRA_TOKEN_BRIDGE_ADDRESS,
// wallet.key.accAddress,
// signedVAA
// );
// const gasPrices = await axios
// .get(TERRA_GAS_PRICES_URL)
// .then((result) => result.data);
// const feeEstimate = await lcd.tx.estimateFee(
// wallet.key.accAddress,
// [msg],
// {
// memo: "localhost",
// feeDenoms: ["uluna"],
// gasPrices,
// }
// );
// const tx = await wallet.createAndSignTx({
// msgs: [msg],
// memo: "localhost",
// feeDenoms: ["uluna"],
// gasPrices,
// fee: feeEstimate,
// });
// await lcd.tx.broadcast(tx);
// expect(
// await getIsTransferCompletedTerra(
// TERRA_TOKEN_BRIDGE_ADDRESS,
// signedVAA,
// wallet.key.accAddress,
// lcd,
// TERRA_GAS_PRICES_URL
// )
// ).toBe(true);
provider.destroy();
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to send from Ethereum to Terra");
}
})();
});
test("Spy Relay redeemed on Terra", (done) => {
(async () => {
try {
const lcd = new LCDClient({
URL: TERRA_NODE_URL,
chainID: TERRA_CHAIN_ID,
});
const mk = new MnemonicKey({
mnemonic: TERRA_PRIVATE_KEY,
});
const wallet = lcd.wallet(mk);
var success: boolean = false;
for (let count = 0; count < 5 && !success; ++count) {
console.log(
"sleeping before querying spy relay",
new Date().toLocaleString()
);
await sleep(5000);
success = await await getIsTransferCompletedTerra(
TERRA_TOKEN_BRIDGE_ADDRESS,
transferSignedVAA,
lcd,
TERRA_GAS_PRICES_URL
);
console.log(
"getIsTransferCompletedTerra returned %d, count is %d",
success,
count
);
}
done();
} catch (e) {
console.error(e);
done(
"An error occurred while checking to see if redeem on Terra was successful"
);
}
})();
});
test("Query Spy Relay via REST", (done) => {
(async () => {
var storeKey: string =
CHAIN_ID_TERRA.toString() +
"/" +
emitterAddress +
"/" +
sequence.toString();
try {
var query: string = SPY_RELAY_URL + "/query/" + storeKey;
console.log("Sending query to spy relay, query: [%s]", query);
const result = await axios.get(query);
console.log(
"status: ",
result.status,
", statusText: ",
result.statusText,
", data: ",
result.data
);
expect(result).toHaveProperty("status");
expect(result.status).toBe(200);
expect(result).toHaveProperty("data");
console.log(result.data);
done();
} catch (e) {
console.error(e);
done("An error occurred while trying to send query to spy relay");
}
})();
});
});

View File

@ -0,0 +1,38 @@
[
{
"chainId": 1,
"chainName": "Solana",
"nativeCurrencySymbol": "SOL",
"nodeUrl": "http://solana-devnet:8899",
"tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
"bridgeAddress": "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o",
"wrappedAsset": "So11111111111111111111111111111111111111112"
},
{
"chainId": 2,
"chainName": "Ethereum",
"nativeCurrencySymbol": "ETH",
"nodeUrl": "http://eth-devnet:8545",
"tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
"wrappedAsset": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"
},
{
"chainId": 3,
"chainName": "Terra",
"nativeCurrencySymbol": "LUNA",
"nodeUrl": "http://terra-terrad:1317",
"tokenBridgeAddress": "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4",
"terraName": "localterra",
"terraChainId": "columbus-5",
"terraCoin": "uluna",
"terraGasPriceUrl": "http://terra-fcd:3060/v1/txs/gas_prices"
},
{
"chainId": 4,
"chainName": "Binance Smart Chain",
"nativeCurrencySymbol": "BNB",
"nodeUrl": "http://eth-devnet2:8546",
"tokenBridgeAddress": "0x0290FB167208Af455bB137780163b7B7a9a10C16",
"wrappedAsset": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E"
}
]

View File

@ -0,0 +1,563 @@
import {
ChainId,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
nativeToHexString,
} from "@certusone/wormhole-sdk";
import { getLogger } from "./helpers/logHelper";
export type SupportedToken = {
chainId: ChainId;
address: string;
};
export type CommonEnvironment = {
logLevel: string;
promPort: number;
readinessPort?: number;
logDir?: string;
redisHost: string;
redisPort: number;
};
let loggingEnv: CommonEnvironment | undefined = undefined;
export const getCommonEnvironment: () => CommonEnvironment = () => {
if (loggingEnv) {
return loggingEnv;
} else {
const env = createCommonEnvironment();
loggingEnv = env;
return loggingEnv;
}
};
function createCommonEnvironment(): CommonEnvironment {
let logLevel;
let promPort;
let readinessPort;
let logDir;
let redisHost;
let redisPort;
if (!process.env.LOG_LEVEL) {
throw new Error("Missing required environment variable: LOG_LEVEL");
} else {
logLevel = process.env.LOG_LEVEL;
}
if (!process.env.LOG_DIR) {
//Not mandatory
} else {
logDir = process.env.LOG_DIR;
}
if (!process.env.PROM_PORT) {
throw new Error("Missing required environment variable: PROM_PORT");
} else {
promPort = parseInt(process.env.PROM_PORT);
}
if (!process.env.READINESS_PORT) {
//do nothing
} else {
readinessPort = parseInt(process.env.READINESS_PORT);
}
if (!process.env.REDIS_HOST) {
throw new Error("Missing required environment variable: REDIS_HOST");
} else {
redisHost = process.env.REDIS_HOST;
}
if (!process.env.REDIS_PORT) {
throw new Error("Missing required environment variable: REDIS_PORT");
} else {
redisPort = parseInt(process.env.REDIS_PORT);
}
return { logLevel, promPort, readinessPort, logDir, redisHost, redisPort };
}
export type RelayerEnvironment = {
supportedChains: ChainConfigInfo[];
redisHost: string;
redisPort: number;
clearRedisOnInit: boolean;
demoteWorkingOnInit: boolean;
supportedTokens: { chainId: ChainId; address: string }[];
};
export type ChainConfigInfo = {
chainId: ChainId;
chainName: string;
nativeCurrencySymbol: string;
nodeUrl: string;
tokenBridgeAddress: string;
walletPrivateKey?: string[];
solanaPrivateKey?: Uint8Array[];
bridgeAddress?: string;
terraName?: string;
terraChainId?: string;
terraCoin?: string;
terraGasPriceUrl?: string;
wrappedAsset?: string | null;
};
export type ListenerEnvironment = {
spyServiceHost: string;
spyServiceFilters: { chainId: ChainId; emitterAddress: string }[];
restPort: number;
numSpyWorkers: number;
supportedTokens: { chainId: ChainId; address: string }[];
};
let listenerEnv: ListenerEnvironment | undefined = undefined;
export const getListenerEnvironment: () => ListenerEnvironment = () => {
if (listenerEnv) {
return listenerEnv;
} else {
const env = createListenerEnvironment();
listenerEnv = env;
return listenerEnv;
}
};
const createListenerEnvironment: () => ListenerEnvironment = () => {
let spyServiceHost: string;
let spyServiceFilters: { chainId: ChainId; emitterAddress: string }[] = [];
let restPort: number;
let numSpyWorkers: number;
let supportedTokens: { chainId: ChainId; address: string }[] = [];
const logger = getLogger();
if (!process.env.SPY_SERVICE_HOST) {
throw new Error("Missing required environment variable: SPY_SERVICE_HOST");
} else {
spyServiceHost = process.env.SPY_SERVICE_HOST;
}
logger.info("Getting SPY_SERVICE_FILTERS...");
if (!process.env.SPY_SERVICE_FILTERS) {
throw new Error(
"Missing required environment variable: SPY_SERVICE_FILTERS"
);
} else {
const array = JSON.parse(process.env.SPY_SERVICE_FILTERS);
// if (!array.foreach) {
if (!array || !Array.isArray(array)) {
throw new Error("Spy service filters is not an array.");
} else {
array.forEach((filter: any) => {
if (filter.chainId && filter.emitterAddress) {
logger.info(
"nativeToHexString: " +
nativeToHexString(filter.emitterAddress, filter.chainId)
);
spyServiceFilters.push({
chainId: filter.chainId as ChainId,
emitterAddress: filter.emitterAddress,
});
} else {
throw new Error("Invalid filter record. " + filter.toString());
}
});
}
}
logger.info("Getting REST_PORT...");
if (!process.env.REST_PORT) {
throw new Error("Missing required environment variable: REST_PORT");
} else {
restPort = parseInt(process.env.REST_PORT);
}
logger.info("Getting SPY_NUM_WORKERS...");
if (!process.env.SPY_NUM_WORKERS) {
throw new Error("Missing required environment variable: SPY_NUM_WORKERS");
} else {
numSpyWorkers = parseInt(process.env.SPY_NUM_WORKERS);
}
logger.info("Getting SUPPORTED_TOKENS...");
if (!process.env.SUPPORTED_TOKENS) {
throw new Error("Missing required environment variable: SUPPORTED_TOKENS");
} else {
// const array = JSON.parse(process.env.SUPPORTED_TOKENS);
const array = eval(process.env.SUPPORTED_TOKENS);
if (!array || !Array.isArray(array)) {
throw new Error("SUPPORTED_TOKENS is not an array.");
} else {
array.forEach((token: any) => {
if (token.chainId && token.address) {
supportedTokens.push({
chainId: token.chainId,
address: token.address,
});
} else {
throw new Error("Invalid token record. " + token.toString());
}
});
}
}
return {
spyServiceHost,
spyServiceFilters,
restPort,
numSpyWorkers,
supportedTokens,
};
};
let relayerEnv: RelayerEnvironment | undefined = undefined;
export const getRelayerEnvironment: () => RelayerEnvironment = () => {
if (relayerEnv) {
return relayerEnv;
} else {
const env = createRelayerEnvironment();
relayerEnv = env;
return relayerEnv;
}
};
const createRelayerEnvironment: () => RelayerEnvironment = () => {
let supportedChains: ChainConfigInfo[] = [];
let redisHost: string;
let redisPort: number;
let clearRedisOnInit: boolean;
let demoteWorkingOnInit: boolean;
let supportedTokens: { chainId: ChainId; address: string }[] = [];
if (!process.env.REDIS_HOST) {
throw new Error("Missing required environment variable: REDIS_HOST");
} else {
redisHost = process.env.REDIS_HOST;
}
if (!process.env.REDIS_PORT) {
throw new Error("Missing required environment variable: REDIS_PORT");
} else {
redisPort = parseInt(process.env.REDIS_PORT);
}
if (process.env.CLEAR_REDIS_ON_INIT === undefined) {
throw new Error(
"Missing required environment variable: CLEAR_REDIS_ON_INIT"
);
} else {
if (process.env.CLEAR_REDIS_ON_INIT.toLowerCase() === "true") {
clearRedisOnInit = true;
} else {
clearRedisOnInit = false;
}
}
if (process.env.DEMOTE_WORKING_ON_INIT === undefined) {
throw new Error(
"Missing required environment variable: DEMOTE_WORKING_ON_INIT"
);
} else {
if (process.env.DEMOTE_WORKING_ON_INIT.toLowerCase() === "true") {
demoteWorkingOnInit = true;
} else {
demoteWorkingOnInit = false;
}
}
supportedChains = loadChainConfig();
if (!process.env.SUPPORTED_TOKENS) {
throw new Error("Missing required environment variable: SUPPORTED_TOKENS");
} else {
// const array = JSON.parse(process.env.SUPPORTED_TOKENS);
const array = eval(process.env.SUPPORTED_TOKENS);
if (!array || !Array.isArray(array)) {
throw new Error("SUPPORTED_TOKENS is not an array.");
} else {
array.forEach((token: any) => {
if (token.chainId && token.address) {
supportedTokens.push({
chainId: token.chainId,
address: token.address,
});
} else {
throw new Error("Invalid token record. " + token.toString());
}
});
}
}
return {
supportedChains,
redisHost,
redisPort,
clearRedisOnInit,
demoteWorkingOnInit,
supportedTokens,
};
};
//Polygon is not supported on local Tilt network atm.
export function loadChainConfig(): ChainConfigInfo[] {
if (!process.env.SUPPORTED_CHAINS) {
throw new Error("Missing required environment variable: SUPPORTED_CHAINS");
}
if (!process.env.PRIVATE_KEYS) {
throw new Error("Missing required environment variable: PRIVATE_KEYS");
}
const unformattedChains = JSON.parse(process.env.SUPPORTED_CHAINS);
const unformattedPrivateKeys = JSON.parse(process.env.PRIVATE_KEYS);
const supportedChains: ChainConfigInfo[] = [];
if (!unformattedChains.forEach) {
throw new Error("SUPPORTED_CHAINS arg was not an array.");
}
if (!unformattedPrivateKeys.forEach) {
throw new Error("PRIVATE_KEYS arg was not an array.");
}
unformattedChains.forEach((element: any) => {
if (!element.chainId) {
throw new Error("Invalid chain config: " + element);
}
const privateKeyObj = unformattedPrivateKeys.find(
(x: any) => x.chainId === element.chainId
);
if (!privateKeyObj) {
throw new Error(
"Failed to find private key object for configured chain ID: " +
element.chainId
);
}
if (element.chainId === CHAIN_ID_SOLANA) {
supportedChains.push(
createSolanaChainConfig(element, privateKeyObj.privateKeys)
);
} else if (element.chainId === CHAIN_ID_TERRA) {
supportedChains.push(
createTerraChainConfig(element, privateKeyObj.privateKeys)
);
} else {
supportedChains.push(
createEvmChainConfig(element, privateKeyObj.privateKeys)
);
}
});
return supportedChains;
}
function createSolanaChainConfig(
config: any,
privateKeys: any[]
): ChainConfigInfo {
let chainId: ChainId;
let chainName: string;
let nativeCurrencySymbol: string;
let nodeUrl: string;
let tokenBridgeAddress: string;
let solanaPrivateKey: Uint8Array[] = [];
let bridgeAddress: string;
let wrappedAsset: string | null;
if (!config.chainId) {
throw new Error("Missing required field in chain config: chainId");
}
if (!config.chainName) {
throw new Error("Missing required field in chain config: chainName");
}
if (!config.nativeCurrencySymbol) {
throw new Error(
"Missing required field in chain config: nativeCurrencySymbol"
);
}
if (!config.nodeUrl) {
throw new Error("Missing required field in chain config: nodeUrl");
}
if (!config.tokenBridgeAddress) {
throw new Error(
"Missing required field in chain config: tokenBridgeAddress"
);
}
if (!(privateKeys && privateKeys.length && privateKeys.forEach)) {
throw new Error(
"Ill formatted object received as private keys for Solana."
);
}
if (!config.bridgeAddress) {
throw new Error("Missing required field in chain config: bridgeAddress");
}
if (!config.wrappedAsset) {
throw new Error("Missing required field in chain config: wrappedAsset");
}
chainId = config.chainId;
chainName = config.chainName;
nativeCurrencySymbol = config.nativeCurrencySymbol;
nodeUrl = config.nodeUrl;
tokenBridgeAddress = config.tokenBridgeAddress;
bridgeAddress = config.bridgeAddress;
wrappedAsset = config.wrappedAsset;
privateKeys.forEach((item: any) => {
try {
const uint = Uint8Array.from(item);
solanaPrivateKey.push(uint);
} catch (e) {
throw new Error(
"Failed to coerce Solana private keys into a uint array. ENV JSON is possibly incorrect."
);
}
});
return {
chainId,
chainName,
nativeCurrencySymbol,
nodeUrl,
tokenBridgeAddress,
bridgeAddress,
solanaPrivateKey,
wrappedAsset,
};
}
function createTerraChainConfig(
config: any,
privateKeys: any[]
): ChainConfigInfo {
let chainId: ChainId;
let chainName: string;
let nativeCurrencySymbol: string;
let nodeUrl: string;
let tokenBridgeAddress: string;
let walletPrivateKey: string[];
let terraName: string;
let terraChainId: string;
let terraCoin: string;
let terraGasPriceUrl: string;
if (!config.chainId) {
throw new Error("Missing required field in chain config: chainId");
}
if (!config.chainName) {
throw new Error("Missing required field in chain config: chainName");
}
if (!config.nativeCurrencySymbol) {
throw new Error(
"Missing required field in chain config: nativeCurrencySymbol"
);
}
if (!config.nodeUrl) {
throw new Error("Missing required field in chain config: nodeUrl");
}
if (!config.tokenBridgeAddress) {
throw new Error(
"Missing required field in chain config: tokenBridgeAddress"
);
}
if (!(privateKeys && privateKeys.length && privateKeys.forEach)) {
throw new Error("Private keys for Terra are length zero or not an array.");
}
if (!config.terraName) {
throw new Error("Missing required field in chain config: terraName");
}
if (!config.terraChainId) {
throw new Error("Missing required field in chain config: terraChainId");
}
if (!config.terraCoin) {
throw new Error("Missing required field in chain config: terraCoin");
}
if (!config.terraGasPriceUrl) {
throw new Error("Missing required field in chain config: terraGasPriceUrl");
}
chainId = config.chainId;
chainName = config.chainName;
nativeCurrencySymbol = config.nativeCurrencySymbol;
nodeUrl = config.nodeUrl;
tokenBridgeAddress = config.tokenBridgeAddress;
walletPrivateKey = privateKeys;
terraName = config.terraName;
terraChainId = config.terraChainId;
terraCoin = config.terraCoin;
terraGasPriceUrl = config.terraGasPriceUrl;
return {
chainId,
chainName,
nativeCurrencySymbol,
nodeUrl,
tokenBridgeAddress,
walletPrivateKey,
terraName,
terraChainId,
terraCoin,
terraGasPriceUrl,
};
}
function createEvmChainConfig(
config: any,
privateKeys: any[]
): ChainConfigInfo {
let chainId: ChainId;
let chainName: string;
let nativeCurrencySymbol: string;
let nodeUrl: string;
let tokenBridgeAddress: string;
let walletPrivateKey: string[];
let wrappedAsset: string;
if (!config.chainId) {
throw new Error("Missing required field in chain config: chainId");
}
if (!config.chainName) {
throw new Error("Missing required field in chain config: chainName");
}
if (!config.nativeCurrencySymbol) {
throw new Error(
"Missing required field in chain config: nativeCurrencySymbol"
);
}
if (!config.nodeUrl) {
throw new Error("Missing required field in chain config: nodeUrl");
}
if (!config.tokenBridgeAddress) {
throw new Error(
"Missing required field in chain config: tokenBridgeAddress"
);
}
if (!(privateKeys && privateKeys.length && privateKeys.forEach)) {
throw new Error(
`Private keys for chain id ${config.chainId} are length zero or not an array.`
);
}
if (!config.wrappedAsset) {
throw new Error("Missing required field in chain config: wrappedAsset");
}
chainId = config.chainId;
chainName = config.chainName;
nativeCurrencySymbol = config.nativeCurrencySymbol;
nodeUrl = config.nodeUrl;
tokenBridgeAddress = config.tokenBridgeAddress;
walletPrivateKey = privateKeys;
wrappedAsset = config.wrappedAsset;
return {
chainId,
chainName,
nativeCurrencySymbol,
nodeUrl,
tokenBridgeAddress,
walletPrivateKey,
wrappedAsset,
};
}

View File

@ -0,0 +1,7 @@
import { config } from "dotenv";
const configFile: string = process.env.SPY_RELAY_CONFIG
? process.env.SPY_RELAY_CONFIG
: ".env.sample";
console.log("loading config file [%s]", configFile);
config({ path: configFile });
export {};

View File

@ -0,0 +1,45 @@
import { beforeAll, test } from "@jest/globals";
import { getLogger, getScopedLogger } from "./logHelper";
// TODO: mock and confirm output
beforeAll(() => {
require("./loadConfig");
process.env.LOG_DIR = ".";
});
test("should log default logs", () => {
const logger = getLogger();
logger.info("test");
});
test("should use child labels", () => {
getLogger().child({}).info("test without labels");
getLogger().child({ labels: [] }).info("test with empty labels");
getLogger()
.child({ labels: ["one"] })
.info("test with one label");
getLogger()
.child({ labels: ["one", "two"] })
.info("test with two labels");
getLogger()
.child({ labels: ["one", "two", "three"] })
.info("test with three labels");
});
test("should allow child label override", () => {
const root = getLogger();
const parent = root.child({ labels: ["override-me"] });
const child = root.child({ labels: ["overridden"] });
root.info("root log");
parent.info("parent log");
child.info("child log");
});
test("scoped logger", () => {
getScopedLogger([]).info("no labels");
getScopedLogger(["one"]).info("one label");
});
test("scoped logger inheritance", () => {
const parent = getScopedLogger(["parent"]);
const child = getScopedLogger(["child"], parent);
parent.info("parent log");
child.info("child log");
});

View File

@ -0,0 +1,91 @@
import winston = require("winston");
import { getCommonEnvironment } from "../configureEnv";
//Be careful not to access this before having called init logger, or it will be undefined
let logger: winston.Logger | undefined;
export function getLogger(): winston.Logger {
if (logger) {
return logger;
} else {
logger = initLogger();
return logger;
}
}
export interface ScopedLogger extends winston.Logger {
scope?: string[];
}
// Child loggers can't override defaultMeta, they add their own defaultRequestMetadata
// ...which is stored in a closure we can't read, so we extend it ourselves :)
// https://github.com/winstonjs/winston/blob/a320b0cf7f3c550a354ce4264d7634ebc60b0a67/lib/winston/logger.js#L45
export function getScopedLogger(
labels: string[],
parentLogger?: ScopedLogger
): ScopedLogger {
const scope = [...(parentLogger?.scope || []), ...labels];
const logger = parentLogger || getLogger();
const child: ScopedLogger = logger.child({
labels: scope,
});
child.scope = scope;
return child;
}
function initLogger(): winston.Logger {
const loggingEnv = getCommonEnvironment();
let useConsole = true;
let logFileName;
if (loggingEnv.logDir) {
useConsole = false;
logFileName =
loggingEnv.logDir + "/spy_relay." + new Date().toISOString() + ".log";
}
let logLevel = loggingEnv.logLevel || "info";
let transport: any;
if (useConsole) {
console.log("spy_relay is logging to the console at level [%s]", logLevel);
transport = new winston.transports.Console({
level: logLevel,
});
} else {
console.log(
"spy_relay is logging to [%s] at level [%s]",
logFileName,
logLevel
);
transport = new winston.transports.File({
filename: logFileName,
level: logLevel,
});
}
const logConfiguration: winston.LoggerOptions = {
// NOTE: do not specify labels in defaultMeta, as it cannot be overridden
transports: [transport],
format: winston.format.combine(
winston.format.splat(),
winston.format.simple(),
winston.format.timestamp({
format: "YYYY-MM-DD HH:mm:ss.SSS",
}),
winston.format.errors({ stack: true }),
winston.format.printf(
(info: any) =>
`${[info.timestamp]}|${info.level}|${
info.labels && info.labels.length > 0
? info.labels.join("|")
: "main"
}: ${info.message}`
)
),
};
return winston.createLogger(logConfiguration);
}

View File

@ -0,0 +1,205 @@
import { ChainId } from "@certusone/wormhole-sdk";
import http = require("http");
import client = require("prom-client");
import { WalletBalance } from "../relayer/walletMonitor";
import { chainIDStrings } from "../utils/wormhole";
import { getScopedLogger } from "./logHelper";
import { RedisTables } from "./redisHelper";
// NOTE: To create a new metric:
// 1) Create a private counter/gauge with appropriate name and help
// 2) Create a method to set the metric to a value
// 3) Register the metric
const logger = getScopedLogger(["prometheusHelpers"]);
export enum PromMode {
Listen,
Relay,
Both,
}
export class PromHelper {
private _register = new client.Registry();
private _mode: PromMode;
private collectDefaultMetrics = client.collectDefaultMetrics;
// Actual metrics (please prefix all metrics with `spy_relay_`)
private successCounter = new client.Counter({
name: "spy_relay_successes",
help: "number of successful relays",
labelNames: ["chain_name"],
});
private failureCounter = new client.Counter({
name: "spy_relay_failures",
help: "number of failed relays",
labelNames: ["chain_name"],
});
private completeTime = new client.Histogram({
name: "spy_relay_complete_time",
help: "Time is took to complete transfer",
buckets: [400, 800, 1600, 3200, 6400, 12800],
});
private listenCounter = new client.Counter({
name: "spy_relay_VAAs_received",
help: "number of VAAs received",
});
private alreadyExecutedCounter = new client.Counter({
name: "spy_relay_already_executed",
help: "number of transfers rejected due to already having been executed",
});
private listenerMemqueue = new client.Gauge({
name: "spy_relay_listener_memqueue_length",
help: "number of items in memory in the listener waiting to be pushed to redis.",
});
private redisQueue = new client.Gauge({
name: "spy_relay_redis_queue_length",
help: "number of items in the pending queue.",
labelNames: ["queue"],
});
// Wallet metrics
private walletBalance = new client.Gauge({
name: "spy_relay_wallet_balance",
help: "Wallet balance for a supported token",
labelNames: [
"currency",
"chain_name",
"wallet",
"currency_address",
"is_native",
],
});
// End metrics
private server = http.createServer(async (req, res) => {
// GKE's ingress-gce doesn't support custom URLs for healthchecks
// without some stupid, so return 200 on / for prometheus to make
// it happy.
if (req.url === "/") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("ok");
res.end();
// The gke ingress-gce does not support stripping path prefixes
} else if (
req.url === "/metrics" ||
req.url === "/relayer" ||
req.url === "/listener"
) {
// Return all metrics in the Prometheus exposition format
if (this._mode === PromMode.Listen || this._mode == PromMode.Both) {
res.setHeader("Content-Type", this._register.contentType);
res.end(await this._register.metrics());
}
if (this._mode === PromMode.Relay || this._mode == PromMode.Both) {
res.setHeader("Content-Type", this._register.contentType);
res.end(await this._register.metrics());
}
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.write("404 Not Found - " + req.url + "\n");
res.end();
}
});
constructor(name: string, port: number, mode: PromMode) {
var mode_name: string = "";
// Human readable mode name for the metrics
if (mode === PromMode.Listen) {
mode_name = "listener";
} else if (mode === PromMode.Relay) {
mode_name = "relayer";
} else if (mode === PromMode.Both) {
mode_name = "both";
}
this._register.setDefaultLabels({
app: name,
mode: mode_name,
});
// Uncomment to collect the default metrics (cpu/memory/nodejs gc stuff/etc)
//this.collectDefaultMetrics({ register: this._register, prefix: "spy_relayer_" });
this._mode = mode;
// Register each metric
if (this._mode === PromMode.Listen || this._mode == PromMode.Both) {
this._register.registerMetric(this.listenCounter);
}
if (this._mode === PromMode.Relay || this._mode == PromMode.Both) {
this._register.registerMetric(this.successCounter);
this._register.registerMetric(this.failureCounter);
this._register.registerMetric(this.alreadyExecutedCounter);
this._register.registerMetric(this.redisQueue);
this._register.registerMetric(this.walletBalance);
}
// End registering metric
this.server.listen(port);
}
// These are the accessor methods for the metrics
incSuccesses(chainId: ChainId) {
this.successCounter
.labels({ chain_name: chainIDStrings[chainId] || "Unknown" })
.inc();
}
incFailures(chainId: ChainId) {
this.failureCounter
.labels({ chain_name: chainIDStrings[chainId] || "Unknown" })
.inc();
}
addCompleteTime(val: number) {
this.completeTime.observe(val);
}
incIncoming() {
this.listenCounter.inc();
}
incAlreadyExec() {
this.alreadyExecutedCounter.inc();
}
handleListenerMemqueue(size: number) {
this.listenerMemqueue.set(size);
}
setRedisQueue(queue: RedisTables, size: number) {
this.redisQueue
.labels({ queue: RedisTables[queue].toLowerCase() })
.set(size);
}
// Wallet metrics
handleWalletBalances(balances: WalletBalance[]) {
const scopedLogger = getScopedLogger(["handleWalletBalances"], logger);
// Walk through each wallet
// create a gauge for the balance
// set the gauge
//this.walletMetrics = [];
for (const bal of balances) {
try {
if (bal.currencyName.length === 0) {
bal.currencyName = "UNK";
}
let formBal: number;
if (!bal.balanceFormatted) {
formBal = 0;
} else {
formBal = parseFloat(bal.balanceFormatted);
}
this.walletBalance
.labels({
currency: bal.currencyName,
chain_name: chainIDStrings[bal.chainId] || "Unknown",
wallet: bal.walletAddress,
currency_address: bal.currencyAddressNative,
is_native: bal.isNative ? "1" : "0",
})
.set(formBal);
} catch (e: any) {
if (e.message) {
scopedLogger.error("Caught error: " + e.message);
} else {
scopedLogger.error("Caught error: %o", e);
}
}
}
}
}

View File

@ -0,0 +1,333 @@
import { ChainId, uint8ArrayToHex } from "@certusone/wormhole-sdk";
import { Mutex } from "async-mutex";
import { createClient } from "redis";
import { getCommonEnvironment } from "../configureEnv";
import { ParsedTransferPayload, ParsedVaa } from "../listener/validation";
import { getScopedLogger } from "./logHelper";
import { PromHelper } from "./promHelpers";
import { sleep } from "./utils";
const logger = getScopedLogger(["redisHelper"]);
const commonEnv = getCommonEnvironment();
const { redisHost, redisPort } = commonEnv;
let promHelper: PromHelper;
//Module internals
const redisMutex = new Mutex();
let redisQueue = new Array<[string, string]>();
export function getBackupQueue() {
return redisQueue;
}
export enum RedisTables {
INCOMING = 0,
WORKING = 1,
}
export function init(ph: PromHelper): boolean {
logger.info("will connect to redis at [" + redisHost + ":" + redisPort + "]");
promHelper = ph;
return true;
}
export async function connectToRedis() {
let rClient;
try {
rClient = createClient({
socket: {
host: redisHost,
port: redisPort,
},
});
rClient.on("connect", function (err) {
if (err) {
logger.error(
"connectToRedis: failed to connect to host [" +
redisHost +
"], port [" +
redisPort +
"]: %o",
err
);
}
});
await rClient.connect();
} catch (e) {
logger.error(
"connectToRedis: failed to connect to host [" +
redisHost +
"], port [" +
redisPort +
"]: %o",
e
);
}
return rClient;
}
export async function storeInRedis(name: string, value: string) {
if (!name) {
logger.error("storeInRedis: missing name");
return;
}
if (!value) {
logger.error("storeInRedis: missing value");
return;
}
await redisMutex.runExclusive(async () => {
logger.debug("storeInRedis: connecting to redis.");
let redisClient;
try {
redisQueue.push([name, value]);
redisClient = await connectToRedis();
if (!redisClient) {
logger.error(
"Failed to connect to redis, enqueued vaa, there are now " +
redisQueue.length +
" enqueued events"
);
return;
}
logger.debug(
"now connected to redis, attempting to push " +
redisQueue.length +
" queued items"
);
for (let item = redisQueue.pop(); item; item = redisQueue.pop()) {
await addToRedis(redisClient, item[0], item[1]);
}
} catch (e) {
logger.error(
"Failed during redis item push. Currently" +
redisQueue.length +
" enqueued items"
);
logger.error(
"encountered an exception while pushing items to redis %o",
e
);
}
try {
if (redisClient) {
await redisClient.quit();
}
} catch (e) {
logger.error("Failed to quit redis client");
}
});
promHelper.handleListenerMemqueue(redisQueue.length);
}
export async function addToRedis(
redisClient: any,
name: string,
value: string
) {
try {
logger.debug("storeInRedis: storing in redis. name: " + name);
await redisClient.select(RedisTables.INCOMING);
await redisClient.set(name, value);
logger.debug("storeInRedis: finished storing in redis.");
} catch (e) {
logger.error(
"storeInRedis: failed to store to host [" +
redisHost +
"], port [" +
redisPort +
"]: %o",
e
);
}
}
export function getKey(chainId: ChainId, address: string) {
return chainId + ":" + address;
}
export enum Status {
Pending = 1,
Completed = 2,
Error = 3,
FatalError = 4,
}
export type RelayResult = {
status: Status;
result: string | null;
};
export type WorkerInfo = {
index: number;
targetChainId: number;
walletPrivateKey: any;
};
export type StoreKey = {
chain_id: number;
emitter_address: string;
sequence: number;
};
export type StorePayload = {
vaa_bytes: string;
status: Status;
timestamp: string;
retries: number;
};
export function initPayload(): StorePayload {
return {
vaa_bytes: "",
status: Status.Pending,
timestamp: new Date().toISOString(),
retries: 0,
};
}
export function initPayloadWithVAA(vaa_bytes: string): StorePayload {
const sp: StorePayload = initPayload();
sp.vaa_bytes = vaa_bytes;
return sp;
}
export function storeKeyFromParsedVAA(
parsedVAA: ParsedVaa<ParsedTransferPayload>
): StoreKey {
return {
chain_id: parsedVAA.emitterChain as number,
emitter_address: uint8ArrayToHex(parsedVAA.emitterAddress),
sequence: parsedVAA.sequence,
};
}
export function storeKeyToJson(storeKey: StoreKey): string {
return JSON.stringify(storeKey);
}
export function storeKeyFromJson(json: string): StoreKey {
return JSON.parse(json);
}
export function storePayloadToJson(storePayload: StorePayload): string {
return JSON.stringify(storePayload);
}
export function storePayloadFromJson(json: string): StorePayload {
return JSON.parse(json);
}
export async function pushVaaToRedis(
parsedVAA: ParsedVaa<ParsedTransferPayload>,
hexVaa: string
) {
const transferPayload = parsedVAA.payload;
logger.info(
"forwarding vaa to relayer: emitter: [" +
parsedVAA.emitterChain +
":" +
uint8ArrayToHex(parsedVAA.emitterAddress) +
"], seqNum: " +
parsedVAA.sequence +
", payload: origin: [" +
transferPayload.originAddress +
":" +
transferPayload.originAddress +
"], target: [" +
transferPayload.targetChain +
":" +
transferPayload.targetAddress +
"], amount: " +
transferPayload.amount +
"], fee: " +
transferPayload.fee +
", "
);
const storeKey = storeKeyFromParsedVAA(parsedVAA);
const storePayload = initPayloadWithVAA(hexVaa);
logger.debug(
"storing: key: [" +
storeKey.chain_id +
"/" +
storeKey.emitter_address +
"/" +
storeKey.sequence +
"], payload: [" +
storePayloadToJson(storePayload) +
"]"
);
await storeInRedis(
storeKeyToJson(storeKey),
storePayloadToJson(storePayload)
);
}
export async function clearRedis() {
const redisClient = await connectToRedis();
if (!redisClient) {
logger.error("Failed to connect to redis to clear tables.");
return;
}
await redisClient.FLUSHALL();
redisClient.quit();
}
export async function demoteWorkingRedis() {
const redisClient = await connectToRedis();
if (!redisClient) {
logger.error("Failed to connect to redis to clear tables.");
return;
}
await redisClient.select(RedisTables.WORKING);
for await (const si_key of redisClient.scanIterator()) {
const si_value = await redisClient.get(si_key);
if (!si_value) {
continue;
}
logger.info("Demoting %s", si_key);
await redisClient.del(si_key);
await redisClient.select(RedisTables.INCOMING);
await redisClient.set(
si_key,
storePayloadToJson(
initPayloadWithVAA(storePayloadFromJson(si_value).vaa_bytes)
)
);
await redisClient.select(RedisTables.WORKING);
}
redisClient.quit();
}
export async function monitorRedis(metrics: PromHelper) {
const scopedLogger = getScopedLogger(["monitorRedis"], logger);
const TEN_SECONDS: number = 10000;
while (true) {
const redisClient = await connectToRedis();
if (!redisClient) {
scopedLogger.error("Failed to connect to redis!");
} else {
try {
await redisClient.select(RedisTables.INCOMING);
metrics.setRedisQueue(RedisTables.INCOMING, await redisClient.dbSize());
await redisClient.select(RedisTables.WORKING);
metrics.setRedisQueue(RedisTables.WORKING, await redisClient.dbSize());
} catch (e) {
scopedLogger.error("Failed to get dbSize and set metrics!");
}
try {
redisClient.quit();
} catch (e) {}
}
await sleep(TEN_SECONDS);
}
}

View File

@ -0,0 +1,463 @@
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
import * as BN from "bn.js";
import { deserializeUnchecked } from "borsh";
import { BinaryReader, BinaryWriter } from "borsh";
import { ChainConfigInfo } from "../configureEnv";
import { getMultipleAccountsRPC } from "../utils/solana";
const base58: any = require("bs58");
// eslint-disable-next-line
export const METADATA_REPLACE = new RegExp("\u0000", "g");
export const EDITION_MARKER_BIT_SIZE = 248;
export const METADATA_PREFIX = "metadata";
export const EDITION = "edition";
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type StringPublicKey = string;
export enum MetadataKey {
Uninitialized = 0,
MetadataV1 = 4,
EditionV1 = 1,
MasterEditionV1 = 2,
MasterEditionV2 = 6,
EditionMarker = 7,
}
export const METADATA_PROGRAM_ID =
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" as StringPublicKey;
export class Data {
name: string;
symbol: string;
uri: string;
// sellerFeeBasisPoints: number;
// creators: Creator[] | null;
constructor(args: {
name: string;
symbol: string;
uri: string;
// sellerFeeBasisPoints: number;
// creators: Creator[] | null;
}) {
this.name = args.name;
this.symbol = args.symbol;
this.uri = args.uri;
// this.sellerFeeBasisPoints = args.sellerFeeBasisPoints;
// this.creators = args.creators;
}
}
class CreateMetadataArgs {
instruction: number = 0;
data: Data;
isMutable: boolean;
constructor(args: { data: Data; isMutable: boolean }) {
this.data = args.data;
this.isMutable = args.isMutable;
}
}
class UpdateMetadataArgs {
instruction: number = 1;
data: Data | null;
// Not used by this app, just required for instruction
updateAuthority: StringPublicKey | null;
primarySaleHappened: boolean | null;
constructor(args: {
data?: Data;
updateAuthority?: string;
primarySaleHappened: boolean | null;
}) {
this.data = args.data ? args.data : null;
this.updateAuthority = args.updateAuthority ? args.updateAuthority : null;
this.primarySaleHappened = args.primarySaleHappened;
}
}
class CreateMasterEditionArgs {
instruction: number = 10;
maxSupply: BN | null;
constructor(args: { maxSupply: BN | null }) {
this.maxSupply = args.maxSupply;
}
}
class MintPrintingTokensArgs {
instruction: number = 9;
supply: BN;
constructor(args: { supply: BN }) {
this.supply = args.supply;
}
}
export class MasterEditionV1 {
key: MetadataKey;
supply: BN;
maxSupply?: BN;
/// Can be used to mint tokens that give one-time permission to mint a single limited edition.
printingMint: StringPublicKey;
/// If you don't know how many printing tokens you are going to need, but you do know
/// you are going to need some amount in the future, you can use a token from this mint.
/// Coming back to token metadata with one of these tokens allows you to mint (one time)
/// any number of printing tokens you want. This is used for instance by Auction Manager
/// with participation NFTs, where we dont know how many people will bid and need participation
/// printing tokens to redeem, so we give it ONE of these tokens to use after the auction is over,
/// because when the auction begins we just dont know how many printing tokens we will need,
/// but at the end we will. At the end it then burns this token with token-metadata to
/// get the printing tokens it needs to give to bidders. Each bidder then redeems a printing token
/// to get their limited editions.
oneTimePrintingAuthorizationMint: StringPublicKey;
constructor(args: {
key: MetadataKey;
supply: BN;
maxSupply?: BN;
printingMint: StringPublicKey;
oneTimePrintingAuthorizationMint: StringPublicKey;
}) {
this.key = MetadataKey.MasterEditionV1;
this.supply = args.supply;
this.maxSupply = args.maxSupply;
this.printingMint = args.printingMint;
this.oneTimePrintingAuthorizationMint =
args.oneTimePrintingAuthorizationMint;
}
}
export class MasterEditionV2 {
key: MetadataKey;
supply: BN;
maxSupply?: BN;
constructor(args: { key: MetadataKey; supply: BN; maxSupply?: BN }) {
this.key = MetadataKey.MasterEditionV2;
this.supply = args.supply;
this.maxSupply = args.maxSupply;
}
}
export class Edition {
key: MetadataKey;
/// Points at MasterEdition struct
parent: StringPublicKey;
/// Starting at 0 for master record, this is incremented for each edition minted.
edition: BN;
constructor(args: {
key: MetadataKey;
parent: StringPublicKey;
edition: BN;
}) {
this.key = MetadataKey.EditionV1;
this.parent = args.parent;
this.edition = args.edition;
}
}
export class Creator {
address: StringPublicKey;
verified: boolean;
share: number;
constructor(args: {
address: StringPublicKey;
verified: boolean;
share: number;
}) {
this.address = args.address;
this.verified = args.verified;
this.share = args.share;
}
}
export class Metadata {
key: MetadataKey;
updateAuthority: StringPublicKey;
mint: StringPublicKey;
data: Data;
primarySaleHappened: boolean;
isMutable: boolean;
editionNonce: number | null;
// set lazy
masterEdition?: StringPublicKey;
edition?: StringPublicKey;
constructor(args: {
updateAuthority: StringPublicKey;
mint: StringPublicKey;
data: Data;
primarySaleHappened: boolean;
isMutable: boolean;
editionNonce: number | null;
}) {
this.key = MetadataKey.MetadataV1;
this.updateAuthority = args.updateAuthority;
this.mint = args.mint;
this.data = args.data;
this.primarySaleHappened = args.primarySaleHappened;
this.isMutable = args.isMutable;
this.editionNonce = args.editionNonce;
}
public async init() {
// const edition = await getEdition(this.mint);
const edition = "0";
this.edition = edition;
this.masterEdition = edition;
}
}
export class EditionMarker {
key: MetadataKey;
ledger: number[];
constructor(args: { key: MetadataKey; ledger: number[] }) {
this.key = MetadataKey.EditionMarker;
this.ledger = args.ledger;
}
editionTaken(edition: number) {
const editionOffset = edition % EDITION_MARKER_BIT_SIZE;
const indexOffset = Math.floor(editionOffset / 8);
if (indexOffset > 30) {
throw new Error("bad index for edition");
}
const positionInBitsetFromRight = 7 - (editionOffset % 8);
const mask = Math.pow(2, positionInBitsetFromRight);
const appliedMask = this.ledger[indexOffset] & mask;
// eslint-disable-next-line
return appliedMask != 0;
}
}
export const METADATA_SCHEMA = new Map<any, any>([
[
CreateMetadataArgs,
{
kind: "struct",
fields: [
["instruction", "u8"],
["data", Data],
["isMutable", "u8"], // bool
],
},
],
[
UpdateMetadataArgs,
{
kind: "struct",
fields: [
["instruction", "u8"],
["data", { kind: "option", type: Data }],
["updateAuthority", { kind: "option", type: "pubkeyAsString" }],
["primarySaleHappened", { kind: "option", type: "u8" }],
],
},
],
[
CreateMasterEditionArgs,
{
kind: "struct",
fields: [
["instruction", "u8"],
["maxSupply", { kind: "option", type: "u64" }],
],
},
],
[
MintPrintingTokensArgs,
{
kind: "struct",
fields: [
["instruction", "u8"],
["supply", "u64"],
],
},
],
[
MasterEditionV1,
{
kind: "struct",
fields: [
["key", "u8"],
["supply", "u64"],
["maxSupply", { kind: "option", type: "u64" }],
["printingMint", "pubkeyAsString"],
["oneTimePrintingAuthorizationMint", "pubkeyAsString"],
],
},
],
[
MasterEditionV2,
{
kind: "struct",
fields: [
["key", "u8"],
["supply", "u64"],
["maxSupply", { kind: "option", type: "u64" }],
],
},
],
[
Edition,
{
kind: "struct",
fields: [
["key", "u8"],
["parent", "pubkeyAsString"],
["edition", "u64"],
],
},
],
[
Data,
{
kind: "struct",
fields: [
["name", "string"],
["symbol", "string"],
["uri", "string"],
["sellerFeeBasisPoints", "u16"],
["creators", { kind: "option", type: [Creator] }],
],
},
],
[
Creator,
{
kind: "struct",
fields: [
["address", "pubkeyAsString"],
["verified", "u8"],
["share", "u8"],
],
},
],
[
Metadata,
{
kind: "struct",
fields: [
["key", "u8"],
["updateAuthority", "pubkeyAsString"],
["mint", "pubkeyAsString"],
["data", Data],
["primarySaleHappened", "u8"], // bool
["isMutable", "u8"], // bool
],
},
],
[
EditionMarker,
{
kind: "struct",
fields: [
["key", "u8"],
["ledger", [31]],
],
},
],
]);
export const extendBorsh = () => {
(BinaryReader.prototype as any).readPubkey = function () {
const reader = this as unknown as BinaryReader;
const array = reader.readFixedArray(32);
return new PublicKey(array);
};
(BinaryWriter.prototype as any).writePubkey = function (value: PublicKey) {
const writer = this as unknown as BinaryWriter;
writer.writeFixedArray(value.toBuffer());
};
(BinaryReader.prototype as any).readPubkeyAsString = function () {
const reader = this as unknown as BinaryReader;
const array = reader.readFixedArray(32);
return base58.encode(array) as StringPublicKey;
};
(BinaryWriter.prototype as any).writePubkeyAsString = function (
value: StringPublicKey
) {
const writer = this as unknown as BinaryWriter;
writer.writeFixedArray(base58.decode(value));
};
};
extendBorsh();
export const decodeMetadata = (buffer: Buffer): Metadata => {
const metadata = deserializeUnchecked(
METADATA_SCHEMA,
Metadata,
buffer
) as Metadata;
metadata.data.name = metadata.data.name.replace(METADATA_REPLACE, "");
metadata.data.uri = metadata.data.uri.replace(METADATA_REPLACE, "");
metadata.data.symbol = metadata.data.symbol.replace(METADATA_REPLACE, "");
return metadata;
};
export const getMetadataAddress = async (
mintKey: string
): Promise<[PublicKey, number]> => {
const seeds = [
Buffer.from("metadata"),
new PublicKey(METADATA_PROGRAM_ID).toBuffer(),
new PublicKey(mintKey).toBuffer(),
];
return PublicKey.findProgramAddress(
seeds,
new PublicKey(METADATA_PROGRAM_ID)
);
};
export const getMetaplexData = async (
mintAddresses: string[],
chainInfo: ChainConfigInfo
) => {
const promises = [];
for (const address of mintAddresses) {
promises.push(getMetadataAddress(address));
}
const metaAddresses = await Promise.all(promises);
// const connection = new Connection(SOLANA_HOST, "confirmed");
const connection = new Connection(chainInfo.nodeUrl, "confirmed");
const results = await getMultipleAccountsRPC(
connection,
metaAddresses.map((pair) => pair && pair[0])
);
const output = results.map((account) => {
if (account === null) {
return undefined;
} else {
if (account.data) {
try {
const MetadataParsed = decodeMetadata(account.data);
return MetadataParsed;
} catch (e) {
// console.error(e);
return undefined;
}
} else {
return undefined;
}
}
});
return output;
};

View File

@ -0,0 +1,81 @@
import { uint8ArrayToHex } from "@certusone/wormhole-sdk";
import { importCoreWasm } from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
import { Request, Response } from "express";
import { getListenerEnvironment, ListenerEnvironment } from "../configureEnv";
import { getLogger } from "../helpers/logHelper";
import {
initPayloadWithVAA,
pushVaaToRedis,
storeInRedis,
storeKeyFromParsedVAA,
storeKeyToJson,
storePayloadToJson,
} from "../helpers/redisHelper";
import {
parseAndValidateVaa,
ParsedTransferPayload,
ParsedVaa,
} from "./validation";
let logger = getLogger();
let env: ListenerEnvironment;
export function init(runRest: boolean): boolean {
if (!runRest) return true;
try {
env = getListenerEnvironment();
} catch (e) {
logger.error(
"Encountered and error while initializing the listener environment: " + e
);
return false;
}
if (!env.restPort) {
return true;
}
return true;
}
export async function run() {
if (!env.restPort) return;
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
app.listen(env.restPort, () =>
logger.info("listening on REST port %d!", env.restPort)
);
(async () => {
app.get("/relayvaa/:vaa", async (req: Request, res: Response) => {
try {
const vaaBuf = Uint8Array.from(Buffer.from(req.params.vaa, "base64"));
const hexVaa = uint8ArrayToHex(vaaBuf);
const validationResults: ParsedVaa<ParsedTransferPayload> | string =
await parseAndValidateVaa(vaaBuf);
if (typeof validationResults === "string") {
logger.debug("Rejecting REST request due validation failure");
return;
}
pushVaaToRedis(validationResults, hexVaa);
res.status(200).json({ message: "Scheduled" });
} catch (e) {
logger.error(
"failed to process rest relay of vaa request, error: %o",
e
);
logger.error("offending request: %o", req);
res.status(400).json({ message: "Request failed" });
}
});
app.get("/", (req: Request, res: Response) =>
res.json(["/relayvaa/<vaaInBase64>"])
);
})();
}

View File

@ -0,0 +1,180 @@
import {
ChainId,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
getEmitterAddressEth,
getEmitterAddressSolana,
getEmitterAddressTerra,
hexToUint8Array,
uint8ArrayToHex,
} from "@certusone/wormhole-sdk";
import {
createSpyRPCServiceClient,
subscribeSignedVAA,
} from "@certusone/wormhole-spydk";
import { getListenerEnvironment, ListenerEnvironment } from "../configureEnv";
import { getLogger } from "../helpers/logHelper";
import { PromHelper } from "../helpers/promHelpers";
import {
initPayloadWithVAA,
pushVaaToRedis,
storeInRedis,
storeKeyFromParsedVAA,
storeKeyToJson,
storePayloadToJson,
} from "../helpers/redisHelper";
import { sleep } from "../helpers/utils";
import {
parseAndValidateVaa,
ParsedTransferPayload,
ParsedVaa,
} from "./validation";
let metrics: PromHelper;
let env: ListenerEnvironment;
let logger = getLogger();
let vaaUriPrelude: string;
export function init(runListen: boolean): boolean {
if (!runListen) return true;
try {
env = getListenerEnvironment();
vaaUriPrelude =
"http://localhost:" +
(process.env.REST_PORT ? process.env.REST_PORT : "4200") +
"/relayvaa/";
} catch (e) {
logger.error("Error initializing listener environment: " + e);
return false;
}
return true;
}
export async function run(ph: PromHelper) {
const logger = getLogger();
metrics = ph;
logger.info("Attempting to run Listener...");
let typedFilters: {
emitterFilter: { chainId: ChainId; emitterAddress: string };
}[] = [];
for (let i = 0; i < env.spyServiceFilters.length; i++) {
logger.info("Getting spyServiceFiltera " + i);
const filter = env.spyServiceFilters[i];
logger.info(
"Getting spyServiceFilter[" +
i +
"]: chainId = " +
filter.chainId +
", emmitterAddress = [" +
filter.emitterAddress +
"]"
);
const typedFilter = {
emitterFilter: {
chainId: filter.chainId as ChainId,
emitterAddress: await encodeEmitterAddress(
filter.chainId,
filter.emitterAddress
),
},
};
logger.info("Getting spyServiceFilterc " + i);
logger.info(
"adding filter: chainId: [" +
typedFilter.emitterFilter.chainId +
"], emitterAddress: [" +
typedFilter.emitterFilter.emitterAddress +
"]"
);
logger.info("Getting spyServiceFilterd " + i);
typedFilters.push(typedFilter);
logger.info("Getting spyServiceFiltere " + i);
}
logger.info(
"spy_relay starting up, will listen for signed VAAs from [" +
env.spyServiceHost +
"]"
);
const wrappedFilters = { filters: typedFilters };
while (true) {
let stream: any;
try {
//TODO use ENV object
const client = createSpyRPCServiceClient(
process.env.SPY_SERVICE_HOST || ""
);
stream = await subscribeSignedVAA(client, wrappedFilters);
//TODO validate that this is the correct type of the vaaBytes
stream.on("data", ({ vaaBytes }: { vaaBytes: Buffer }) => {
const asUint8 = new Uint8Array(vaaBytes);
processVaa(asUint8);
});
let connected = true;
stream.on("error", (err: any) => {
logger.error("spy service returned an error: %o", err);
connected = false;
});
stream.on("close", () => {
logger.error("spy service closed the connection!");
connected = false;
});
logger.info(
"connected to spy service, listening for transfer signed VAAs"
);
while (connected) {
await sleep(1000);
}
} catch (e) {
logger.error("spy service threw an exception: %o", e);
}
stream.end;
await sleep(5 * 1000);
logger.info("attempting to reconnect to the spy service");
}
}
async function processVaa(rawVaa: Uint8Array) {
//TODO, verify this is correct & potentially swap to using hex encoding
const vaaUri =
vaaUriPrelude + encodeURIComponent(Buffer.from(rawVaa).toString("base64"));
const validationResults: ParsedVaa<ParsedTransferPayload> | string =
await parseAndValidateVaa(rawVaa);
metrics.incIncoming();
if (typeof validationResults === "string") {
logger.debug("Rejecting spied request due validation failure");
return;
}
const parsedVAA: ParsedVaa<ParsedTransferPayload> = validationResults;
await pushVaaToRedis(parsedVAA, uint8ArrayToHex(rawVaa));
}
async function encodeEmitterAddress(
myChainId: ChainId,
emitterAddressStr: string
): Promise<string> {
if (myChainId === CHAIN_ID_SOLANA) {
return await getEmitterAddressSolana(emitterAddressStr);
}
if (myChainId === CHAIN_ID_TERRA) {
return await getEmitterAddressTerra(emitterAddressStr);
}
return getEmitterAddressEth(emitterAddressStr);
}

View File

@ -0,0 +1,238 @@
import {
ChainId,
hexToNativeString,
parseTransferPayload,
uint8ArrayToHex,
} from "@certusone/wormhole-sdk";
import { importCoreWasm } from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
import { getListenerEnvironment } from "../configureEnv";
import { getLogger } from "../helpers/logHelper";
import {
connectToRedis,
getBackupQueue,
getKey,
RedisTables,
} from "../helpers/redisHelper";
const logger = getLogger();
export function validateInit(): boolean {
const env = getListenerEnvironment();
logger.info(
"supported target chains: [" + env.spyServiceFilters.toString() + "]"
);
if (env.spyServiceFilters.length) {
env.spyServiceFilters.forEach((allowedContract) => {
logger.info(
"adding allowed contract: chainId: [" +
allowedContract.chainId +
"] => address: [" +
allowedContract.emitterAddress +
"]"
);
});
} else {
logger.info("There are no white listed contracts provisioned.");
}
logger.info("supported tokens : [" + env.supportedTokens.toString() + "]");
if (env.supportedTokens.length) {
env.supportedTokens.forEach((supportedToken) => {
logger.info(
"adding allowed contract: chainId: [" +
supportedToken.chainId +
"] => address: [" +
supportedToken.address +
"]" +
" key: " +
getKey(supportedToken.chainId, supportedToken.address)
);
});
} else {
logger.info("There are no white listed contracts provisioned.");
}
return true;
}
export async function parseAndValidateVaa(
rawVaa: Uint8Array
): Promise<string | ParsedVaa<ParsedTransferPayload>> {
logger.debug("About to validate: " + uint8ArrayToHex(rawVaa));
let parsedVaa: ParsedVaa<Uint8Array> | null = null;
try {
parsedVaa = await parseVaaTyped(rawVaa);
} catch (e) {
logger.error("Encountered error while parsing raw VAA " + e);
}
if (!parsedVaa) {
return "Unable to parse the specified VAA.";
}
const env = getListenerEnvironment();
//You have to derive all the emitter addresses from the native addresses, because emitter addresses cannot be mapped backwards to native.
//This is especially important because they are only uninvertible on Solana, and if you convert the emitter addresses to native,
//It will work for all chains except Solana.
//TODO calc emitter addresses, and compare against those, rather than getting the natives from the emitter
// const nativeAddress = hexToNativeString(
// uint8ArrayToHex(parsedVaa.emitterAddress),
// parsedVaa.emitterChain
// );
// logger.info("nativeAddress format for emitter address in validator:" + nativeAddress);
// const isApprovedAddress = env.spyServiceFilters.find((allowedContract) => {
// console.log(
// parsedVaa,
// nativeAddress,
// allowedContract.emitterAddress,
// "in approved address"
// );
// return (
// parsedVaa &&
// nativeAddress &&
// allowedContract.chainId === parsedVaa.emitterChain &&
// allowedContract.emitterAddress.toLowerCase() ===
// nativeAddress.toLowerCase()
// );
// });
// if (!isApprovedAddress) {
// logger.debug("Specified vaa is not from an approved address.");
// return "VAA is not from a monitored contract.";
// }
const isCorrectPayloadType = parsedVaa.payload[0] === 1;
if (!isCorrectPayloadType) {
logger.debug("Specified vaa is not payload type 1.");
return "Specified vaa is not payload type 1..";
}
let parsedPayload: any = null;
try {
parsedPayload = parseTransferPayload(Buffer.from(parsedVaa.payload));
} catch (e) {
logger.error("Encountered error while parsing vaa payload" + e);
}
if (!parsedPayload) {
logger.debug("Failed to parse the transfer payload.");
return "Could not parse the transfer payload.";
}
const originAddressNative = hexToNativeString(
parsedPayload.originAddress,
parsedPayload.originChain
);
const isApprovedToken = env.supportedTokens.find((token) => {
return (
originAddressNative &&
token.address.toLowerCase() === originAddressNative.toLowerCase() &&
token.chainId === parsedPayload.originChain
);
});
if (!isApprovedToken) {
logger.debug("Token transfer is not for an approved token.");
return "Token transfer is not for an approved token.";
}
//TODO configurable
const sufficientFee = parsedPayload.fee && parsedPayload.fee > 0;
if (!sufficientFee) {
logger.debug("Token transfer does not have a sufficient fee.");
return "Token transfer does not have a sufficient fee.";
}
const key = getKey(parsedPayload.originChain, originAddressNative as string); //was null checked above
const isQueued = await checkQueue(key);
if (isQueued) {
return isQueued;
}
//TODO maybe an is redeemed check?
const fullyTyped = { ...parsedVaa, payload: parsedPayload };
return fullyTyped;
}
async function checkQueue(key: string): Promise<string | null> {
try {
const backupQueue = getBackupQueue();
const queuedRecord = backupQueue.find((record) => {
record[0] === key;
});
if (queuedRecord) {
logger.debug("VAA was already in the listener queue");
return "VAA was already in the listener queue";
}
const rClient = await connectToRedis();
if (!rClient) {
logger.error("Failed to connect to redis");
return null;
}
await rClient.select(RedisTables.INCOMING);
const record1 = await rClient.get(key);
if (record1) {
logger.debug("VAA was already in INCOMING table");
rClient.quit();
return "VAA was already in INCOMING table";
}
await rClient.select(RedisTables.WORKING);
const record2 = await rClient.get(key);
if (record2) {
logger.debug("VAA was already in WORKING table");
rClient.quit();
return "VAA was already in WORKING table";
}
rClient.quit();
} catch (e) {
logger.error("Failed to connect to redis");
}
return null;
}
//TODO move these to the official SDK
export async function parseVaaTyped(signedVAA: Uint8Array) {
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(signedVAA);
return {
timestamp: parseInt(parsedVAA.timestamp),
nonce: parseInt(parsedVAA.nonce),
emitterChain: parseInt(parsedVAA.emitter_chain) as ChainId,
emitterAddress: parsedVAA.emitter_address, //This will be in wormhole HEX format
sequence: parseInt(parsedVAA.sequence),
consistencyLevel: parseInt(parsedVAA.consistency_level),
payload: parsedVAA.payload,
};
}
export type ParsedVaa<T> = {
timestamp: number;
nonce: number;
emitterChain: ChainId;
emitterAddress: Uint8Array;
sequence: number;
consistencyLevel: number;
payload: T;
};
export type ParsedTransferPayload = {
amount: BigInt;
originAddress: Uint8Array; //hex
originChain: ChainId;
targetAddress: Uint8Array; //hex
targetChain: ChainId;
fee?: BigInt;
};

View File

@ -0,0 +1,103 @@
//This has to run first so that the process variables are set up when the other modules are instantiated.
require("./helpers/loadConfig");
import { setDefaultWasm } from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
import { getCommonEnvironment } from "./configureEnv";
import { getLogger } from "./helpers/logHelper";
import { PromHelper, PromMode } from "./helpers/promHelpers";
import * as redisHelper from "./helpers/redisHelper";
import * as restListener from "./listener/rest_listen";
import * as spyListener from "./listener/spy_listen";
import * as relayWorker from "./relayer/relay_worker";
export enum ProcessType {
LISTEN_ONLY = "--listen_only",
RELAY_ONLY = "--relay_only",
SPY_AND_RELAY = "spy and relay",
}
setDefaultWasm("node");
const logger = getLogger();
// Load the relay config data.
let runListen: boolean = true;
let runWorker: boolean = true;
let runRest: boolean = true;
let foundOne: boolean = false;
let error: string = "";
for (let idx = 0; idx < process.argv.length; ++idx) {
if (process.argv[idx] === "--listen_only") {
if (foundOne) {
logger.error('May only specify one of "--listen_only" or "--relay_only"');
error = "Multiple args found of --listen_only and --relay_only";
break;
}
logger.info("spy_relay is running in listen only mode");
runWorker = false;
foundOne = true;
}
if (process.argv[idx] === "--relay_only") {
if (foundOne) {
logger.error(
'May only specify one of "--listen_only", "--relay_only" or "--rest_only"'
);
error = "Multiple args found of --listen_only and --relay_only";
break;
}
logger.info("spy_relay is running in relay only mode");
runListen = false;
runRest = false;
foundOne = true;
}
}
if (!foundOne) {
logger.info("spy_relay is running both the listener and relayer");
}
if (
!error &&
spyListener.init(runListen) &&
relayWorker.init(runWorker) &&
restListener.init(runRest)
) {
const commonEnv = getCommonEnvironment();
const { promPort, readinessPort } = commonEnv;
logger.info("prometheus client listening on port " + promPort);
let promClient: PromHelper;
const runBoth: boolean = runListen && runWorker;
if (runBoth) {
promClient = new PromHelper("spy_relay", promPort, PromMode.Both);
} else if (runListen) {
promClient = new PromHelper("spy_relay", promPort, PromMode.Listen);
} else if (runWorker) {
promClient = new PromHelper("spy_relay", promPort, PromMode.Relay);
} else {
logger.error("Invalid run mode for Prometheus");
promClient = new PromHelper("spy_relay", promPort, PromMode.Both);
}
redisHelper.init(promClient);
if (runListen) spyListener.run(promClient);
if (runWorker) relayWorker.run(promClient);
if (runRest) restListener.run();
if (readinessPort) {
const Net = require("net");
const readinessServer = new Net.Server();
readinessServer.listen(readinessPort, function () {
logger.info("listening for readiness requests on port " + readinessPort);
});
readinessServer.on("connection", function (socket: any) {
//logger.debug("readiness connection");
});
}
} else {
logger.error("Initialization failed.");
}

View File

@ -0,0 +1,32 @@
[
{
"chainId": 1,
"privateKeys": [
[
14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22,
161, 89, 84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164,
152, 70, 87, 65, 8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177,
247, 77, 19, 112, 47, 44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145,
132, 141
]
]
},
{
"chainId": 2,
"privateKeys": [
"0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
]
},
{
"chainId": 3,
"privateKeys": [
"notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius"
]
},
{
"chainId": 4,
"privateKeys": [
"0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
]
}
]

View File

@ -0,0 +1,106 @@
import {
CHAIN_ID_POLYGON,
getIsTransferCompletedEth,
hexToUint8Array,
redeemOnEth,
redeemOnEthNative,
} from "@certusone/wormhole-sdk";
import { Signer } from "@ethersproject/abstract-signer";
import { ethers } from "ethers";
import { ChainConfigInfo } from "../configureEnv";
import { getScopedLogger, ScopedLogger } from "../helpers/logHelper";
export function newProvider(
url: string,
batch: boolean = false
): ethers.providers.JsonRpcProvider | ethers.providers.JsonRpcBatchProvider {
// only support http(s), not ws(s) as the websocket constructor can blow up the entire process
// it uses a nasty setTimeout(()=>{},0) so we are unable to cleanly catch its errors
if (url.startsWith("http")) {
if (batch) {
return new ethers.providers.JsonRpcBatchProvider(url);
}
return new ethers.providers.JsonRpcProvider(url);
}
throw new Error("url does not start with http/https!");
}
export async function relayEVM(
chainConfigInfo: ChainConfigInfo,
signedVAA: string,
unwrapNative: boolean,
checkOnly: boolean,
walletPrivateKey: string,
relayLogger: ScopedLogger
) {
const logger = getScopedLogger(
["evm", chainConfigInfo.chainName],
relayLogger
);
const signedVaaArray = hexToUint8Array(signedVAA);
let provider = newProvider(chainConfigInfo.nodeUrl);
const signer: Signer = new ethers.Wallet(walletPrivateKey, provider);
if (unwrapNative) {
logger.info(
"Will redeem and unwrap using pubkey: %s",
await signer.getAddress()
);
} else {
logger.info("Will redeem using pubkey: %s", await signer.getAddress());
}
logger.debug("Checking to see if vaa has already been redeemed.");
const alreadyRedeemed = await getIsTransferCompletedEth(
chainConfigInfo.tokenBridgeAddress,
provider,
signedVaaArray
);
if (alreadyRedeemed) {
logger.info("VAA has already been redeemed!");
return { redeemed: true, result: "already redeemed" };
}
if (checkOnly) {
return { redeemed: false, result: "not redeemed" };
}
logger.debug("Redeeming.");
// look, there's something janky with Polygon + ethers + EIP-1559
let overrides;
if (chainConfigInfo.chainId === CHAIN_ID_POLYGON) {
let feeData = await provider.getFeeData();
overrides = {
maxFeePerGas: feeData.maxFeePerGas?.mul(50) || undefined,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas?.mul(50) || undefined,
};
}
const receipt = unwrapNative
? await redeemOnEthNative(
chainConfigInfo.tokenBridgeAddress,
signer,
signedVaaArray,
overrides
)
: await redeemOnEth(
chainConfigInfo.tokenBridgeAddress,
signer,
signedVaaArray,
overrides
);
logger.debug("Checking to see if the transaction is complete.");
const success = await getIsTransferCompletedEth(
chainConfigInfo.tokenBridgeAddress,
provider,
signedVaaArray
);
if (provider instanceof ethers.providers.WebSocketProvider) {
await provider.destroy();
}
logger.info("success: %s tx hash: %s", success, receipt.transactionHash);
return { redeemed: success, result: receipt };
}

View File

@ -0,0 +1,125 @@
import { importCoreWasm } from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
import {
ChainId,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
hexToUint8Array,
isEVMChain,
parseTransferPayload,
} from "@certusone/wormhole-sdk";
import { relayEVM } from "./evm";
import { relaySolana } from "./solana";
import { relayTerra } from "./terra";
import { getRelayerEnvironment } from "../configureEnv";
import { RelayResult, Status } from "../helpers/redisHelper";
import { getLogger, getScopedLogger, ScopedLogger } from "../helpers/logHelper";
const logger = getLogger();
function getChainConfigInfo(chainId: ChainId) {
const env = getRelayerEnvironment();
return env.supportedChains.find((x) => x.chainId === chainId);
}
export async function relay(
signedVAA: string,
checkOnly: boolean,
walletPrivateKey: any,
relayLogger: ScopedLogger
): Promise<RelayResult> {
const logger = getScopedLogger(["relay"], relayLogger);
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(hexToUint8Array(signedVAA));
if (parsedVAA.payload[0] === 1) {
const transferPayload = parseTransferPayload(
Buffer.from(parsedVAA.payload)
);
const chainConfigInfo = getChainConfigInfo(transferPayload.targetChain);
if (!chainConfigInfo) {
logger.error("relay: improper chain ID: " + transferPayload.targetChain);
return {
status: Status.FatalError,
result:
"Fatal Error: target chain " +
transferPayload.targetChain +
" not supported",
};
}
if (isEVMChain(transferPayload.targetChain)) {
const unwrapNative =
transferPayload.originAddress.toLowerCase() ===
chainConfigInfo.wrappedAsset?.toLowerCase();
logger.debug(
"isEVMChain: originAddress: [" +
transferPayload.originAddress +
"], wrappedAsset: [" +
chainConfigInfo.wrappedAsset +
"], unwrapNative: " +
unwrapNative
);
let evmResult = await relayEVM(
chainConfigInfo,
signedVAA,
unwrapNative,
checkOnly,
walletPrivateKey,
logger
);
return {
status: evmResult.redeemed ? Status.Completed : Status.Error,
result: evmResult.result.toString(),
};
}
if (transferPayload.targetChain === CHAIN_ID_SOLANA) {
let rResult: RelayResult = { status: Status.Error, result: "" };
const retVal = await relaySolana(
chainConfigInfo,
signedVAA,
checkOnly,
walletPrivateKey,
logger
);
if (retVal.redeemed) {
rResult.status = Status.Completed;
}
rResult.result = retVal.result;
return rResult;
}
if (transferPayload.targetChain === CHAIN_ID_TERRA) {
let rResult: RelayResult = { status: Status.Error, result: "" };
const retVal = await relayTerra(
chainConfigInfo,
signedVAA,
checkOnly,
walletPrivateKey,
logger
);
if (retVal.redeemed) {
rResult.status = Status.Completed;
}
rResult.result = retVal.result;
return rResult;
}
logger.error(
"relay: target chain ID: " +
transferPayload.targetChain +
" is invalid, this is a program bug!"
);
return {
status: Status.FatalError,
result:
"Fatal Error: target chain " +
transferPayload.targetChain +
" is invalid, this is a program bug!",
};
}
return { status: Status.FatalError, result: "ERROR: Invalid payload type" };
}

View File

@ -0,0 +1,523 @@
import { hexToUint8Array, parseTransferPayload } from "@certusone/wormhole-sdk";
import { importCoreWasm } from "@certusone/wormhole-sdk/lib/cjs/solana/wasm";
import { getRelayerEnvironment, RelayerEnvironment } from "../configureEnv";
import { getLogger, getScopedLogger, ScopedLogger } from "../helpers/logHelper";
import { PromHelper } from "../helpers/promHelpers";
import {
clearRedis,
connectToRedis,
demoteWorkingRedis,
monitorRedis,
RedisTables,
RelayResult,
Status,
StorePayload,
storePayloadFromJson,
storePayloadToJson,
WorkerInfo,
} from "../helpers/redisHelper";
import { sleep } from "../helpers/utils";
import { relay } from "./relay";
import { collectWallets } from "./walletMonitor";
const WORKER_THREAD_RESTART_MS = 10 * 1000;
const AUDITOR_THREAD_RESTART_MS = 10 * 1000;
const AUDIT_INTERVAL_MS = 30 * 1000;
const WORKER_INTERVAL_MS = 5 * 1000;
const REDIS_RETRY_MS = 10 * 1000;
let metrics: PromHelper;
const logger = getLogger();
let relayerEnv: RelayerEnvironment;
type WorkableItem = {
key: string;
value: string;
};
export function init(runWorker: boolean): boolean {
if (!runWorker) return true;
try {
relayerEnv = getRelayerEnvironment();
} catch (e) {
logger.error(
"Encountered error while initiating the relayer environment: " + e
);
return false;
}
return true;
}
function createWorkerInfos() {
let workerArray: WorkerInfo[] = new Array();
let index = 0;
relayerEnv.supportedChains.forEach((chain) => {
chain.walletPrivateKey?.forEach((key) => {
workerArray.push({
walletPrivateKey: key,
index: index,
targetChainId: chain.chainId,
});
index++;
});
chain.solanaPrivateKey?.forEach((key) => {
workerArray.push({
walletPrivateKey: key,
index: index,
targetChainId: chain.chainId,
});
index++;
});
});
logger.info("will use " + workerArray.length + " workers");
return workerArray;
}
async function spawnWorkerThreads(workerArray: WorkerInfo[]) {
workerArray.forEach((workerInfo) => {
spawnWorkerThread(workerInfo);
spawnAuditorThread(workerInfo);
});
}
async function spawnAuditorThread(workerInfo: WorkerInfo) {
logger.info(
"Spinning up auditor thread[" +
workerInfo.index +
"] to handle targetChainId " +
workerInfo.targetChainId
);
//At present, due to the try catch inside the while loop, this thread should never crash.
const auditorPromise = doAuditorThread(workerInfo).catch(
async (error: Error) => {
logger.error(
"Fatal crash on auditor thread: index " +
workerInfo.index +
" chainId " +
workerInfo.targetChainId
);
logger.error("error message: " + error.message);
logger.error("error trace: " + error.stack);
await sleep(AUDITOR_THREAD_RESTART_MS);
spawnAuditorThread(workerInfo);
}
);
return auditorPromise;
}
//One auditor thread should be spawned per worker. This is perhaps overkill, but auditors
//should not be allowed to block workers, or other auditors.
async function doAuditorThread(workerInfo: WorkerInfo) {
const auditLogger = getScopedLogger([`audit-worker-${workerInfo.index}`]);
while (true) {
try {
let redisClient: any = null;
while (!redisClient) {
redisClient = await connectToRedis();
if (!redisClient) {
auditLogger.error("Failed to connect to redis!");
await sleep(REDIS_RETRY_MS);
}
}
await redisClient.select(RedisTables.WORKING);
for await (const si_key of redisClient.scanIterator()) {
const si_value = await redisClient.get(si_key);
if (!si_value) {
continue;
}
const storePayload: StorePayload = storePayloadFromJson(si_value);
try {
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(hexToUint8Array(storePayload.vaa_bytes));
const payloadBuffer: Buffer = Buffer.from(parsedVAA.payload);
const transferPayload = parseTransferPayload(payloadBuffer);
const chain = transferPayload.targetChain;
if (chain !== workerInfo.targetChainId) {
continue;
}
} catch (e) {
auditLogger.error("Failed to parse a stored VAA: " + e);
auditLogger.error("si_value of failure: " + si_value);
continue;
}
auditLogger.debug(
"key %s => status: %s, timestamp: %s, retries: %d",
si_key,
Status[storePayload.status],
storePayload.timestamp,
storePayload.retries
);
// Let things sit in here for 10 minutes
// After that:
// - Toss totally failed VAAs
// - Check to see if successful transactions were rolled back
// - Put roll backs into INCOMING table
// - Toss legitimately completed transactions
const now = new Date();
const old = new Date(storePayload.timestamp);
const timeDelta = now.getTime() - old.getTime(); // delta is in mS
const TEN_MINUTES = 600000;
auditLogger.debug(
"Checking timestamps: now: " +
now.toISOString() +
", old: " +
old.toISOString() +
", delta: " +
timeDelta
);
if (timeDelta > TEN_MINUTES) {
// Deal with this item
if (storePayload.status === Status.FatalError) {
// Done with this failed transaction
auditLogger.debug("Discarding FatalError.");
await redisClient.del(si_key);
continue;
} else if (storePayload.status === Status.Completed) {
// Check for rollback
auditLogger.debug("Checking for rollback.");
//TODO actually do an isTransferCompleted
const rr = await relay(
storePayload.vaa_bytes,
true,
workerInfo.walletPrivateKey,
auditLogger
);
await redisClient.del(si_key);
if (rr.status !== Status.Completed) {
auditLogger.info("Detected a rollback on " + si_key);
// Remove this item from the WORKING table and move it to INCOMING
await redisClient.select(RedisTables.INCOMING);
await redisClient.set(si_key, si_value);
await redisClient.select(RedisTables.WORKING);
}
} else if (storePayload.status === Status.Error) {
auditLogger.error("Received Error status.");
continue;
} else if (storePayload.status === Status.Pending) {
auditLogger.error("Received Pending status.");
continue;
} else {
auditLogger.error("Unhandled Status of " + storePayload.status);
continue;
}
}
}
redisClient.quit();
// metrics.setDemoWalletBalance(now.getUTCSeconds());
await sleep(AUDIT_INTERVAL_MS);
} catch (e) {
auditLogger.error("spawnAuditorThread: caught exception: " + e);
}
}
}
export async function run(ph: PromHelper) {
metrics = ph;
if (relayerEnv.clearRedisOnInit) {
logger.info("Clearing REDIS as per tunable...");
await clearRedis();
} else if (relayerEnv.demoteWorkingOnInit) {
logger.info("Demoting Working to Incoming as per tunable...");
await demoteWorkingRedis();
} else {
logger.info("NOT clearing REDIS.");
}
let workerArray: WorkerInfo[] = createWorkerInfos();
spawnWorkerThreads(workerArray);
try {
collectWallets(metrics);
} catch (e) {
logger.error("Failed to kick off collectWallets: " + e);
}
try {
monitorRedis(metrics);
} catch (e) {
logger.error("Failed to kick off monitorRedis: " + e);
}
}
async function processRequest(
key: string,
myPrivateKey: any,
relayLogger: ScopedLogger
) {
const logger = getScopedLogger(["processRequest"], relayLogger);
try {
logger.debug("Processing request %s...", key);
// Get the entry from the working store
const rClient = await connectToRedis();
if (!rClient) {
logger.error("Failed to connect to Redis in processRequest");
return;
}
await rClient.select(RedisTables.WORKING);
let value: string | null = await rClient.get(key);
if (!value) {
logger.error("Could not find key %s", key);
return;
}
let payload: StorePayload = storePayloadFromJson(value);
if (payload.status !== Status.Pending) {
logger.info("This key %s has already been processed.", key);
return;
}
// Actually do the processing here and update status and time field
let relayResult: RelayResult;
try {
if (payload.retries > 0) {
logger.info(
"Calling with vaa_bytes %s, retry %d",
payload.vaa_bytes,
payload.retries
);
} else {
logger.info("Calling with vaa_bytes %s", payload.vaa_bytes);
}
relayResult = await relay(payload.vaa_bytes, false, myPrivateKey, logger);
logger.info("Relay returned: %o", Status[relayResult.status]);
} catch (e: any) {
if (e.message) {
logger.error("Failed to relay transfer vaa: %s", e.message);
} else {
logger.error("Failed to relay transfer vaa: %o", e);
}
relayResult = {
status: Status.Error,
result: "Failure",
};
if (e && e.message) {
relayResult.result = e.message;
}
}
const MAX_RETRIES = 10;
let targetChain: any = 0; // 0 is unspecified, but not covered by the SDK
try {
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(hexToUint8Array(payload.vaa_bytes));
const transferPayload = parseTransferPayload(
Buffer.from(parsedVAA.payload)
);
targetChain = transferPayload.targetChain;
} catch (e) {}
let retry: boolean = false;
if (relayResult.status === Status.Completed) {
metrics.incSuccesses(targetChain);
} else {
metrics.incFailures(targetChain);
if (payload.retries >= MAX_RETRIES) {
relayResult.status = Status.FatalError;
}
if (relayResult.status === Status.FatalError) {
// Invoke fatal error logic here!
payload.retries = MAX_RETRIES;
} else {
// Invoke retry logic here!
retry = true;
}
}
// Put result back into store
payload.status = relayResult.status;
payload.timestamp = new Date().toISOString();
payload.retries++;
value = storePayloadToJson(payload);
if (!retry || payload.retries > MAX_RETRIES) {
await rClient.set(key, value);
} else {
// Remove from the working table
await rClient.del(key);
// Put this back into the incoming table
await rClient.select(RedisTables.INCOMING);
await rClient.set(key, value);
}
await rClient.quit();
} catch (e: any) {
logger.error("Unexpected error in processRequest: " + e.message);
logger.error("request key: " + key);
logger.error(e);
return [];
}
}
// Redis does not guarantee ordering. Therefore, it is possible that if workItems are
// pulled out one at a time, then some workItems could stay in the table indefinitely.
// This function gathers all the items available at this moment to work on.
async function findWorkableItems(
workerInfo: WorkerInfo,
relayLogger: ScopedLogger
): Promise<WorkableItem[]> {
const logger = getScopedLogger(["findWorkableItems"], relayLogger);
try {
let workableItems: WorkableItem[] = [];
const redisClient = await connectToRedis();
if (!redisClient) {
logger.error("Failed to connect to redis inside findWorkableItems()!");
return workableItems;
}
await redisClient.select(RedisTables.INCOMING);
for await (const si_key of redisClient.scanIterator()) {
const si_value = await redisClient.get(si_key);
if (si_value) {
let storePayload: StorePayload = storePayloadFromJson(si_value);
// Check to see if this worker should handle this VAA
if (workerInfo.targetChainId !== 0) {
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(hexToUint8Array(storePayload.vaa_bytes));
const payloadBuffer: Buffer = Buffer.from(parsedVAA.payload);
const transferPayload = parseTransferPayload(payloadBuffer);
const tgtChainId = transferPayload.targetChain;
if (tgtChainId !== workerInfo.targetChainId) {
// Skipping mismatched chainId
continue;
}
}
// Check to see if this is a retry and if it is time to retry
if (storePayload.retries > 0) {
const BACKOFF_TIME = 1000; // 1 second in milliseconds
const MAX_BACKOFF_TIME = 4 * 60 * 60 * 1000; // 4 hours in milliseconds
// calculate retry time
const now: Date = new Date();
const old: Date = new Date(storePayload.timestamp);
const timeDelta: number = now.getTime() - old.getTime(); // delta is in mS
const waitTime: number = Math.min(
BACKOFF_TIME * 10 ** storePayload.retries, //First retry is 10 second, then 100, 1,000... Max of 4 hours.
MAX_BACKOFF_TIME
);
if (timeDelta < waitTime) {
// Not enough time has passed
continue;
}
}
workableItems.push({ key: si_key, value: si_value });
}
}
redisClient.quit();
return workableItems;
} catch (e: any) {
logger.error(
"Recoverable exception scanning REDIS for workable items: " + e.message
);
logger.error(e);
return [];
}
}
//One worker should be spawned for each chainId+privateKey combo.
async function spawnWorkerThread(workerInfo: WorkerInfo) {
logger.info(
"Spinning up worker[" +
workerInfo.index +
"] to handle targetChainId " +
workerInfo.targetChainId
);
const workerPromise = doWorkerThread(workerInfo).catch(async (error) => {
logger.error(
"Fatal crash on worker thread: index " +
workerInfo.index +
" chainId " +
workerInfo.targetChainId
);
logger.error("error message: " + error.message);
logger.error("error trace: " + error.stack);
await sleep(WORKER_THREAD_RESTART_MS);
spawnWorkerThread(workerInfo);
});
return workerPromise;
}
async function doWorkerThread(workerInfo: WorkerInfo) {
const relayLogger = getScopedLogger([`relay-worker-${workerInfo.index}`]);
while (true) {
// relayLogger.debug("Finding workable items.");
const workableItems: WorkableItem[] = await findWorkableItems(
workerInfo,
relayLogger
);
// relayLogger.debug("Found items: %o", workableItems);
let i: number = 0;
for (i = 0; i < workableItems.length; i++) {
const workItem: WorkableItem = workableItems[i];
if (workItem) {
//This will attempt to move the workable item to the WORKING table
relayLogger.debug("Moving item: %o", workItem);
if (await moveToWorking(workItem, relayLogger)) {
relayLogger.info("Moved key to WORKING table: %s", workItem.key);
await processRequest(
workItem.key,
workerInfo.walletPrivateKey,
relayLogger
);
} else {
relayLogger.error(
"Cannot move work item from INCOMING to WORKING: %s",
workItem.key
);
}
}
}
// relayLogger.debug(
// "Taking a break for %i seconds",
// WORKER_INTERVAL_MS / 1000
// );
await sleep(WORKER_INTERVAL_MS);
}
}
async function moveToWorking(
workItem: WorkableItem,
relayLogger: ScopedLogger
): Promise<boolean> {
const logger = getScopedLogger(["moveToWorking"], relayLogger);
try {
const redisClient = await connectToRedis();
if (!redisClient) {
logger.error("Failed to connect to Redis.");
return false;
}
// Move this entry from incoming store to working store
await redisClient.select(RedisTables.INCOMING);
if ((await redisClient.del(workItem.key)) === 0) {
logger.info("The key %s no longer exists in INCOMING", workItem.key);
await redisClient.quit();
return false;
}
await redisClient.select(RedisTables.WORKING);
// If this VAA is already in the working store, then no need to add it again.
// This handles the case of duplicate VAAs from multiple guardians
const checkVal = await redisClient.get(workItem.key);
if (!checkVal) {
let payload: StorePayload = storePayloadFromJson(workItem.value);
payload.status = Status.Pending;
await redisClient.set(workItem.key, storePayloadToJson(payload));
await redisClient.quit();
return true;
} else {
metrics.incAlreadyExec();
logger.debug("Dropping request %s as already processed", workItem.key);
await redisClient.quit();
return false;
}
} catch (e: any) {
logger.error("Recoverable exception moving item to working: " + e.message);
logger.error("%s => %s", workItem.key, workItem.value);
logger.error(e);
return false;
}
}

View File

@ -0,0 +1,168 @@
import {
CHAIN_ID_SOLANA,
getForeignAssetSolana,
getIsTransferCompletedSolana,
hexToNativeString,
hexToUint8Array,
importCoreWasm,
parseTransferPayload,
postVaaSolanaWithRetry,
redeemOnSolana,
} from "@certusone/wormhole-sdk";
import {
ASSOCIATED_TOKEN_PROGRAM_ID,
Token,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
import { ChainConfigInfo } from "../configureEnv";
import { getScopedLogger, ScopedLogger } from "../helpers/logHelper";
const MAX_VAA_UPLOAD_RETRIES_SOLANA = 5;
export async function relaySolana(
chainConfigInfo: ChainConfigInfo,
signedVAAString: string,
checkOnly: boolean,
walletPrivateKey: Uint8Array,
relayLogger: ScopedLogger
) {
const logger = getScopedLogger(["solana"], relayLogger);
//TODO native transfer & create associated token account
//TODO close connection
const signedVaaArray = hexToUint8Array(signedVAAString);
const signedVaaBuffer = Buffer.from(signedVaaArray);
const connection = new Connection(chainConfigInfo.nodeUrl, "confirmed");
if (!chainConfigInfo.bridgeAddress) {
// This should never be the case, as enforced by createSolanaChainConfig
return { redeemed: false, result: null };
}
const keypair = Keypair.fromSecretKey(walletPrivateKey);
const payerAddress = keypair.publicKey.toString();
logger.info(
"publicKey: %s, bridgeAddress: %s, tokenBridgeAddress: %s",
payerAddress,
chainConfigInfo.bridgeAddress,
chainConfigInfo.tokenBridgeAddress
);
logger.debug("Checking to see if vaa has already been redeemed.");
const alreadyRedeemed = await getIsTransferCompletedSolana(
chainConfigInfo.tokenBridgeAddress,
signedVaaArray,
connection
);
if (alreadyRedeemed) {
logger.info("VAA has already been redeemed!");
return { redeemed: true, result: "already redeemed" };
}
if (checkOnly) {
return { redeemed: false, result: "not redeemed" };
}
// determine fee destination address - an associated token account
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(signedVaaArray);
const payloadBuffer = Buffer.from(parsedVAA.payload);
const transferPayload = parseTransferPayload(payloadBuffer);
logger.debug("Calculating the fee destination address");
const solanaMintAddress =
transferPayload.originChain === CHAIN_ID_SOLANA
? hexToNativeString(transferPayload.originAddress, CHAIN_ID_SOLANA)
: await getForeignAssetSolana(
connection,
chainConfigInfo.tokenBridgeAddress,
transferPayload.originChain,
hexToUint8Array(transferPayload.originAddress)
);
if (!solanaMintAddress) {
throw new Error(
`Unable to determine mint for origin chain: ${
transferPayload.originChain
}, address: ${transferPayload.originAddress} (${hexToNativeString(
transferPayload.originAddress,
transferPayload.originChain
)})`
);
}
const solanaMintKey = new PublicKey(solanaMintAddress);
const feeRecipientAddress = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
solanaMintKey,
keypair.publicKey
);
// create the associated token account if it doesn't exist
const associatedAddressInfo = await connection.getAccountInfo(
feeRecipientAddress
);
if (!associatedAddressInfo) {
logger.debug(
"Fee destination address %s for wallet %s, mint %s does not exist, creating it.",
feeRecipientAddress.toString(),
keypair.publicKey,
solanaMintAddress
);
const transaction = new Transaction().add(
await Token.createAssociatedTokenAccountInstruction(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
solanaMintKey,
feeRecipientAddress,
keypair.publicKey, // owner
keypair.publicKey // payer
)
);
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = keypair.publicKey;
// sign, send, and confirm transaction
transaction.partialSign(keypair);
const txid = await connection.sendRawTransaction(transaction.serialize());
await connection.confirmTransaction(txid);
}
logger.debug("Posting the vaa.");
await postVaaSolanaWithRetry(
connection,
async (transaction) => {
transaction.partialSign(keypair);
return transaction;
},
chainConfigInfo.bridgeAddress,
payerAddress,
signedVaaBuffer,
MAX_VAA_UPLOAD_RETRIES_SOLANA
);
logger.debug("Redeeming.");
const unsignedTransaction = await redeemOnSolana(
connection,
chainConfigInfo.bridgeAddress,
chainConfigInfo.tokenBridgeAddress,
payerAddress,
signedVaaArray,
feeRecipientAddress.toString()
);
logger.debug("Sending.");
unsignedTransaction.partialSign(keypair);
const txid = await connection.sendRawTransaction(
unsignedTransaction.serialize()
);
await connection.confirmTransaction(txid);
logger.debug("Checking to see if the transaction is complete.");
const success = await getIsTransferCompletedSolana(
chainConfigInfo.tokenBridgeAddress,
signedVaaArray,
connection
);
logger.info("success: %s, tx hash: %s", success, txid);
return { redeemed: success, result: txid };
}

View File

@ -0,0 +1,117 @@
import {
getIsTransferCompletedTerra,
hexToUint8Array,
redeemOnTerra,
} from "@certusone/wormhole-sdk";
import { LCDClient, MnemonicKey } from "@terra-money/terra.js";
import axios from "axios";
import { ChainConfigInfo } from "../configureEnv";
import { getScopedLogger, ScopedLogger } from "../helpers/logHelper";
export async function relayTerra(
chainConfigInfo: ChainConfigInfo,
signedVAA: string,
checkOnly: boolean,
walletPrivateKey: any,
relayLogger: ScopedLogger
) {
const logger = getScopedLogger(["terra"], relayLogger);
if (
!(
chainConfigInfo.terraChainId &&
chainConfigInfo.terraCoin &&
chainConfigInfo.terraGasPriceUrl &&
chainConfigInfo.terraName
)
) {
logger.error("Terra relay was called without proper instantiation.");
throw new Error("Terra relay was called without proper instantiation.");
}
const signedVaaArray = hexToUint8Array(signedVAA);
const lcdConfig = {
URL: chainConfigInfo.nodeUrl,
chainID: chainConfigInfo.terraChainId,
name: chainConfigInfo.terraName,
};
const lcd = new LCDClient(lcdConfig);
const mk = new MnemonicKey({
mnemonic: walletPrivateKey,
});
const wallet = lcd.wallet(mk);
logger.info(
"terraChainId: %s, tokenBridgeAddress: %s, accAddress: %s, signedVAA: $s",
chainConfigInfo.terraChainId,
chainConfigInfo.tokenBridgeAddress,
wallet.key.accAddress,
signedVAA
);
logger.debug("Checking to see if vaa has already been redeemed.");
const alreadyRedeemed = await getIsTransferCompletedTerra(
chainConfigInfo.tokenBridgeAddress,
signedVaaArray,
lcd,
chainConfigInfo.terraGasPriceUrl
);
if (alreadyRedeemed) {
logger.info("VAA has already been redeemed!");
return { redeemed: true, result: "already redeemed" };
}
if (checkOnly) {
return { redeemed: false, result: "not redeemed" };
}
const msg = await redeemOnTerra(
chainConfigInfo.tokenBridgeAddress,
wallet.key.accAddress,
signedVaaArray
);
logger.debug("Getting gas prices");
//let gasPrices = await lcd.config.gasPrices //Unsure if the values returned from this are hardcoded or not.
//Thus, we are going to pull it directly from the current FCD.
const gasPrices = await axios
.get(chainConfigInfo.terraGasPriceUrl)
.then((result) => result.data);
logger.debug("Estimating fees");
const account = await lcd.auth.accountInfo(wallet.key.accAddress);
const feeEstimate = await lcd.tx.estimateFee(
[
{
sequenceNumber: account.getSequenceNumber(),
publicKey: account.getPublicKey(),
},
],
{
msgs: [msg],
feeDenoms: [chainConfigInfo.terraCoin],
gasPrices,
}
);
logger.debug("createAndSign");
const tx = await wallet.createAndSignTx({
msgs: [msg],
memo: "Relayer - Complete Transfer",
feeDenoms: [chainConfigInfo.terraCoin],
gasPrices,
fee: feeEstimate,
});
logger.debug("Broadcasting");
const receipt = await lcd.tx.broadcast(tx);
logger.debug("Checking to see if the transaction is complete.");
const success = await getIsTransferCompletedTerra(
chainConfigInfo.tokenBridgeAddress,
signedVaaArray,
lcd,
chainConfigInfo.terraGasPriceUrl
);
logger.info("success: %s, tx hash: %s", success, receipt.txhash);
return { redeemed: success, result: receipt.txhash };
}

View File

@ -0,0 +1,40 @@
require("../helpers/loadConfig");
process.env.LOG_DIR = ".";
import { CHAIN_ID_BSC } from "@certusone/wormhole-sdk";
import { jest, test } from "@jest/globals";
import { ChainConfigInfo } from "../configureEnv";
import { pullEVMBalance } from "./walletMonitor";
jest.setTimeout(300000);
const bscChainConfig: ChainConfigInfo = {
chainId: CHAIN_ID_BSC,
chainName: "BSC",
nativeCurrencySymbol: "BNB",
nodeUrl: "https://bsc-dataseed.binance.org",
tokenBridgeAddress: "0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7",
wrappedAsset: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
};
const bscPublicKey = "0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7"; // Token Bridge
const bscTokens = [
"0xfA54fF1a158B5189Ebba6ae130CEd6bbd3aEA76e", // SOL
"0x4DB5a66E937A9F4473fA95b1cAF1d1E1D62E29EA", // WETH
"0x156ab3346823B651294766e23e6Cf87254d68962", // LUNA
"0x3d4350cD54aeF9f9b2C29435e0fa809957B3F30a", // UST
"0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", // WBNB
"0xc836d8dC361E44DbE64c4862D55BA041F88Ddd39", // WMATIC
"0x96412902aa9aFf61E13f085e70D3152C6ef2a817", // WAVAX
"0x6c6D604D3f07aBE287C1A3dF0281e999A83495C0", // wROSE
"0xbF8413EE8612E0E4f66Aa63B5ebE27f3C5883d47", // WFTM
"0xB04906e95AB5D797aDA81508115611fee694c2b3", // USDC
"0x524bC91Dc82d6b90EF29F76A3ECAaBAffFD490Bc", // USDT
];
test("should pull EVM token balances", async () => {
for (let address of bscTokens) {
const balance = await pullEVMBalance(bscChainConfig, bscPublicKey, address);
console.log(balance);
expect(balance).toBeTruthy();
}
});

View File

@ -0,0 +1,597 @@
import {
Bridge__factory,
ChainId,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
getForeignAssetTerra,
hexToUint8Array,
isEVMChain,
nativeToHexString,
WSOL_DECIMALS,
} from "@certusone/wormhole-sdk";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { Connection, Keypair } from "@solana/web3.js";
import { LCDClient, MnemonicKey } from "@terra-money/terra.js";
import { ethers, Signer } from "ethers";
import { formatUnits } from "ethers/lib/utils";
import {
ChainConfigInfo,
getRelayerEnvironment,
RelayerEnvironment,
SupportedToken,
} from "../configureEnv";
import { getScopedLogger } from "../helpers/logHelper";
import { PromHelper } from "../helpers/promHelpers";
import { getMetaplexData, sleep } from "../helpers/utils";
import { getEthereumToken } from "../utils/ethereum";
import { getMultipleAccountsRPC } from "../utils/solana";
import { formatNativeDenom } from "../utils/terra";
import { newProvider } from "./evm";
let env: RelayerEnvironment;
const logger = getScopedLogger(["walletMonitor"]);
export type WalletBalance = {
chainId: ChainId;
balanceAbs: string;
balanceFormatted?: string;
currencyName: string;
currencyAddressNative: string;
isNative: boolean;
walletAddress: string;
};
export interface TerraNativeBalances {
[index: string]: string;
}
function init() {
try {
env = getRelayerEnvironment();
} catch (e) {
logger.error("Unable to instantiate the relayerEnv in wallet monitor");
}
}
async function pullBalances(metrics: PromHelper): Promise<WalletBalance[]> {
//TODO loop through all the chain configs, calc the public keys, pull their balances, and push to a combo of the loggers and prmometheus
logger.debug("pulling balances...");
if (!env) {
logger.error("pullBalances() - no env");
return [];
}
if (!env.supportedChains) {
logger.error("pullBalances() - no supportedChains");
return [];
}
const balancePromises: Promise<WalletBalance[]>[] = [];
for (const chainInfo of env.supportedChains) {
if (!chainInfo) break;
for (const privateKey of chainInfo.walletPrivateKey || []) {
try {
if (!privateKey) break;
logger.debug(
"Attempting to pull native balance for chainId: " + chainInfo.chainId
);
if (isEVMChain(chainInfo.chainId)) {
logger.info("Attempting to pull EVM native balance...");
try {
balancePromises.push(pullEVMNativeBalance(chainInfo, privateKey));
} catch (e) {
logger.error("pullEVMNativeBalance() failed: " + e);
}
logger.info("Attempting to pull EVM non-native balance...");
pullAllEVMTokens(env.supportedTokens, chainInfo, metrics);
} else if (chainInfo.chainId === CHAIN_ID_TERRA) {
logger.info("Attempting to pull TERRA native balance...");
balancePromises.push(pullTerraNativeBalance(chainInfo, privateKey));
logger.info("Attempting to pull TERRA non-native balance...");
balancePromises.push(
pullAllTerraTokens(env.supportedTokens, chainInfo)
);
} else {
logger.error(
"Invalid chain ID in wallet monitor " + chainInfo.chainId
);
}
} catch (e: any) {
logger.error(
"pulling balances failed failed for chain: " + chainInfo.chainName
);
if (e && e.stack) {
logger.error(e.stack);
}
}
}
for (const solanaPrivateKey of chainInfo.solanaPrivateKey || []) {
try {
if (chainInfo.chainId === CHAIN_ID_SOLANA) {
logger.info("pullBalances() - calling pullSolanaNativeBalance...");
balancePromises.push(
pullSolanaNativeBalance(chainInfo, solanaPrivateKey)
);
logger.info("pullBalances() - calling pullSolanaTokenBalances...");
balancePromises.push(
pullSolanaTokenBalances(chainInfo, solanaPrivateKey)
);
}
} catch (e: any) {
logger.error(
"pulling balances failed failed for chain: " + chainInfo.chainName
);
if (e && e.stack) {
logger.error(e.stack);
}
}
}
}
const balancesArrays = await Promise.all(balancePromises);
const balances = balancesArrays.reduce(
(prev, curr) => [...prev, ...curr],
[]
);
return balances;
}
export async function pullEVMBalance(
chainInfo: ChainConfigInfo,
publicAddress: string,
tokenAddress: string
): Promise<WalletBalance> {
let provider = newProvider(chainInfo.nodeUrl);
const token = await getEthereumToken(tokenAddress, provider);
const decimals = await token.decimals();
const balance = await token.balanceOf(publicAddress);
const symbol = await token.symbol();
const balanceFormatted = formatUnits(balance, decimals);
return {
chainId: chainInfo.chainId,
balanceAbs: balance.toString(),
balanceFormatted: balanceFormatted,
currencyName: symbol,
currencyAddressNative: tokenAddress,
isNative: false,
walletAddress: publicAddress,
};
}
async function pullTerraBalance(
chainInfo: ChainConfigInfo,
walletPrivateKey: string,
tokenAddress: string
): Promise<WalletBalance | undefined> {
if (
!(
chainInfo.terraChainId &&
chainInfo.terraCoin &&
chainInfo.terraGasPriceUrl &&
chainInfo.terraName
)
) {
logger.error("Terra relay was called without proper instantiation.");
throw new Error("Terra relay was called without proper instantiation.");
}
const lcdConfig = {
URL: chainInfo.nodeUrl,
chainID: chainInfo.terraChainId,
name: chainInfo.terraName,
};
const lcd = new LCDClient(lcdConfig);
const mk = new MnemonicKey({
mnemonic: walletPrivateKey,
});
const wallet = lcd.wallet(mk);
const walletAddress = wallet.key.accAddress;
const tokenInfo: any = await lcd.wasm.contractQuery(tokenAddress, {
token_info: {},
});
const balanceInfo: any = lcd.wasm.contractQuery(tokenAddress, {
balance: {
address: walletAddress,
},
});
if (!tokenInfo || !balanceInfo) {
return undefined;
}
return {
chainId: CHAIN_ID_TERRA,
balanceAbs: balanceInfo?.balance?.toString() || "0",
balanceFormatted: formatUnits(
balanceInfo?.balance?.toString() || "0",
tokenInfo.decimals
),
currencyName: tokenInfo.symbol,
currencyAddressNative: tokenAddress,
isNative: false,
walletAddress: walletAddress,
};
}
async function pullSolanaTokenBalances(
chainInfo: ChainConfigInfo,
privateKey: Uint8Array
): Promise<WalletBalance[]> {
const keyPair = Keypair.fromSecretKey(privateKey);
const connection = new Connection(chainInfo.nodeUrl);
const output: WalletBalance[] = [];
try {
const allAccounts = await connection.getParsedTokenAccountsByOwner(
keyPair.publicKey,
{ programId: TOKEN_PROGRAM_ID },
"confirmed"
);
let mintAddresses: string[] = [];
allAccounts.value.forEach((account) => {
mintAddresses.push(account.account.data.parsed?.info?.mint);
});
const mdArray = await getMetaplexData(mintAddresses, chainInfo);
for (const account of allAccounts.value) {
let mintAddress: string[] = [];
mintAddress.push(account.account.data.parsed?.info?.mint);
const mdArray = await getMetaplexData(mintAddress, chainInfo);
let cName: string = "";
if (mdArray && mdArray[0] && mdArray[0].data && mdArray[0].data.symbol) {
const encoded = mdArray[0].data.symbol;
cName = encodeURIComponent(encoded);
cName = cName.replace(/%/g, "_");
}
output.push({
chainId: CHAIN_ID_SOLANA,
balanceAbs: account.account.data.parsed?.info?.tokenAmount?.amount,
balanceFormatted:
account.account.data.parsed?.info?.tokenAmount?.uiAmount,
currencyName: cName,
currencyAddressNative: account.account.data.parsed?.info?.mint,
isNative: false,
walletAddress: account.pubkey.toString(),
});
}
} catch (e) {
logger.error("pullSolanaTokenBalances() - ", e);
}
return output;
}
async function pullEVMNativeBalance(
chainInfo: ChainConfigInfo,
privateKey: string
): Promise<WalletBalance[]> {
if (!privateKey || !chainInfo.nodeUrl) {
throw new Error("Bad chainInfo config for EVM chain: " + chainInfo.chainId);
}
let provider = newProvider(chainInfo.nodeUrl);
if (!provider) throw new Error("bad provider");
const signer: Signer = new ethers.Wallet(privateKey, provider);
const addr: string = await signer.getAddress();
const weiAmount = await provider.getBalance(addr);
const balanceInEth = ethers.utils.formatEther(weiAmount);
return [
{
chainId: chainInfo.chainId,
balanceAbs: weiAmount.toString(),
balanceFormatted: balanceInEth.toString(),
currencyName: chainInfo.nativeCurrencySymbol,
currencyAddressNative: "",
isNative: true,
walletAddress: addr,
},
];
}
async function pullTerraNativeBalance(
chainInfo: ChainConfigInfo,
privateKey: string
): Promise<WalletBalance[]> {
const output: WalletBalance[] = [];
if (
!(
chainInfo.terraChainId &&
chainInfo.terraCoin &&
chainInfo.terraGasPriceUrl &&
chainInfo.terraName
)
) {
logger.error(
"Terra wallet balance was called without proper instantiation."
);
throw new Error(
"Terra wallet balance was called without proper instantiation."
);
}
const lcdConfig = {
URL: chainInfo.nodeUrl,
chainID: chainInfo.terraChainId,
name: chainInfo.terraName,
};
const lcd = new LCDClient(lcdConfig);
const mk = new MnemonicKey({
mnemonic: privateKey,
});
const wallet = lcd.wallet(mk);
const walletAddress = wallet.key.accAddress;
const [coins] = await lcd.bank.balance(walletAddress);
// coins doesn't support reduce
const balancePairs = coins.map(({ amount, denom }) => [denom, amount]);
const balance = balancePairs.reduce((obj, current) => {
obj[current[0].toString()] = current[1].toString();
return obj;
}, {} as TerraNativeBalances);
Object.keys(balance).forEach((key) => {
output.push({
chainId: chainInfo.chainId,
balanceAbs: balance[key],
balanceFormatted: formatUnits(balance[key], 6).toString(),
currencyName: formatNativeDenom(key),
currencyAddressNative: key,
isNative: true,
walletAddress: walletAddress,
});
});
return output;
}
async function pullSolanaNativeBalance(
chainInfo: ChainConfigInfo,
privateKey: Uint8Array
): Promise<WalletBalance[]> {
const keyPair = Keypair.fromSecretKey(privateKey);
const connection = new Connection(chainInfo.nodeUrl);
const fetchAccounts = await getMultipleAccountsRPC(connection, [
keyPair.publicKey,
]);
if (!fetchAccounts[0]) {
//Accounts with zero balance report as not existing.
return [
{
chainId: chainInfo.chainId,
balanceAbs: "0",
balanceFormatted: "0",
currencyName: chainInfo.nativeCurrencySymbol,
currencyAddressNative: chainInfo.chainName,
isNative: true,
walletAddress: keyPair.publicKey.toString(),
},
];
}
const amountLamports = fetchAccounts[0].lamports.toString();
const amountSol = formatUnits(
fetchAccounts[0].lamports,
WSOL_DECIMALS
).toString();
return [
{
chainId: chainInfo.chainId,
balanceAbs: amountLamports,
balanceFormatted: amountSol,
currencyName: chainInfo.nativeCurrencySymbol,
currencyAddressNative: "",
isNative: true,
walletAddress: keyPair.publicKey.toString(),
},
];
}
export async function collectWallets(metrics: PromHelper) {
const scopedLogger = getScopedLogger(["collectWallets"], logger);
const ONE_MINUTE: number = 60000;
scopedLogger.info("Starting up.");
init();
while (true) {
scopedLogger.debug("Pulling balances.");
let wallets: WalletBalance[] = [];
try {
wallets = await pullBalances(metrics);
} catch (e) {
scopedLogger.error("Failed to pullBalances: " + e);
}
scopedLogger.debug("Done pulling balances.");
metrics.handleWalletBalances(wallets);
await sleep(ONE_MINUTE);
}
}
async function calcLocalAddressesEVM(
provider: ethers.providers.JsonRpcBatchProvider,
supportedTokens: SupportedToken[],
chainConfigInfo: ChainConfigInfo
): Promise<string[]> {
const tokenBridge = Bridge__factory.connect(
chainConfigInfo.tokenBridgeAddress,
provider
);
let tokenAddressPromises: Promise<string>[] = [];
for (const supportedToken of supportedTokens) {
if (supportedToken.chainId === chainConfigInfo.chainId) {
tokenAddressPromises.push(Promise.resolve(supportedToken.address));
continue;
}
const hexAddress = nativeToHexString(
supportedToken.address,
supportedToken.chainId
);
if (!hexAddress) {
logger.debug(
"calcLocalAddressesEVM() - no hexAddress for chainId: " +
supportedToken.chainId +
", address: " +
supportedToken.address
);
continue;
}
tokenAddressPromises.push(
tokenBridge.wrappedAsset(
supportedToken.chainId,
hexToUint8Array(hexAddress)
)
);
}
return (await Promise.all(tokenAddressPromises)).filter(
(tokenAddress) =>
tokenAddress && tokenAddress !== ethers.constants.AddressZero
);
}
async function calcLocalAddressesTerra(
supportedTokens: SupportedToken[],
chainConfigInfo: ChainConfigInfo
) {
if (
!(
chainConfigInfo.terraChainId &&
chainConfigInfo.terraCoin &&
chainConfigInfo.terraGasPriceUrl &&
chainConfigInfo.terraName
)
) {
logger.error(
"Terra wallet balance was called without proper instantiation."
);
throw new Error(
"Terra wallet balance was called without proper instantiation."
);
}
const lcdConfig = {
URL: chainConfigInfo.nodeUrl,
chainID: chainConfigInfo.terraChainId,
name: chainConfigInfo.terraName,
};
const lcd = new LCDClient(lcdConfig);
const output: string[] = [];
for (const supportedToken of supportedTokens) {
if (supportedToken.chainId === chainConfigInfo.chainId) {
// skip natives, like uluna and uusd
if (supportedToken.address.startsWith("terra")) {
output.push(supportedToken.address);
}
continue;
}
const hexAddress = nativeToHexString(
supportedToken.address,
supportedToken.chainId
);
if (!hexAddress) {
continue;
}
//This returns a native address
let foreignAddress;
try {
foreignAddress = await getForeignAssetTerra(
chainConfigInfo.tokenBridgeAddress,
lcd,
supportedToken.chainId,
hexToUint8Array(hexAddress)
);
} catch (e) {
logger.error("Foreign address exception.");
}
if (!foreignAddress) {
continue;
}
output.push(foreignAddress);
}
return output;
}
async function pullAllEVMTokens(
supportedTokens: SupportedToken[],
chainConfig: ChainConfigInfo,
metrics: PromHelper
) {
let provider = newProvider(
chainConfig.nodeUrl,
true
) as ethers.providers.JsonRpcBatchProvider;
const localAddresses = await calcLocalAddressesEVM(
provider,
supportedTokens,
chainConfig
);
if (!chainConfig.walletPrivateKey) {
return;
}
for (const privateKey of chainConfig.walletPrivateKey) {
try {
const publicAddress = await new ethers.Wallet(privateKey).getAddress();
const tokens = await Promise.all(
localAddresses.map((tokenAddress) =>
getEthereumToken(tokenAddress, provider)
)
);
const tokenInfos = await Promise.all(
tokens.map((token) =>
Promise.all([
token.decimals(),
token.balanceOf(publicAddress),
token.symbol(),
])
)
);
const balances = tokenInfos.map(([decimals, balance, symbol], idx) => ({
chainId: chainConfig.chainId,
balanceAbs: balance.toString(),
balanceFormatted: formatUnits(balance, decimals),
currencyName: symbol,
currencyAddressNative: localAddresses[idx],
isNative: false,
walletAddress: publicAddress,
}));
metrics.handleWalletBalances(balances);
} catch (e) {
logger.error(
"pollEVMBalance failed: for tokens " +
JSON.stringify(localAddresses) +
" on chain " +
chainConfig.chainId +
", error: " +
e
);
}
}
}
async function pullAllTerraTokens(
supportedTokens: SupportedToken[],
chainConfig: ChainConfigInfo
) {
const localAddresses = await calcLocalAddressesTerra(
supportedTokens,
chainConfig
);
const output: WalletBalance[] = [];
if (!chainConfig.walletPrivateKey) {
return output;
}
for (const privateKey of chainConfig.walletPrivateKey) {
for (const address of localAddresses) {
const balance = await pullTerraBalance(chainConfig, privateKey, address);
if (balance) {
output.push(balance);
}
}
}
// logger.debug("pullAllTerraTokens() - returning %o", output);
return output;
}

View File

@ -0,0 +1,10 @@
import { TokenImplementation__factory } from "@certusone/wormhole-sdk";
import { ethers } from "ethers";
export async function getEthereumToken(
tokenAddress: string,
provider: ethers.providers.Provider
) {
const token = TokenImplementation__factory.connect(tokenAddress, provider);
return token;
}

View File

@ -0,0 +1,37 @@
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
export async function getMultipleAccountsRPC(
connection: Connection,
pubkeys: PublicKey[]
): Promise<(AccountInfo<Buffer> | null)[]> {
return getMultipleAccounts(connection, pubkeys, "confirmed");
}
export const getMultipleAccounts = async (
connection: any,
pubkeys: PublicKey[],
commitment: string
) => {
return (
await Promise.all(
chunks(pubkeys, 99).map((chunk) =>
connection.getMultipleAccountsInfo(chunk, commitment)
)
)
).flat();
};
export function chunks<T>(array: T[], size: number): T[][] {
return Array.apply<number, T[], T[][]>(
0,
new Array(Math.ceil(array.length / size))
).map((_, index) => array.slice(index * size, (index + 1) * size));
}
export function shortenAddress(address: string) {
return address.length > 10
? `${address.slice(0, 4)}...${address.slice(-4)}`
: address;
}
export const WSOL_DECIMALS = 9;

View File

@ -0,0 +1,12 @@
import { isNativeTerra } from "@certusone/wormhole-sdk";
// inspired by https://github.com/terra-money/station/blob/dca7de43958ce075c6e46605622203b9859b0e14/src/lib/utils/format.ts#L38
export const formatNativeDenom = (denom = ""): string => {
const unit = denom.slice(1).toUpperCase();
const isValidTerra = isNativeTerra(denom);
return denom === "uluna"
? "Luna"
: isValidTerra
? unit.slice(0, 2) + "T"
: "";
};

View File

@ -0,0 +1,17 @@
import { ChainId } from "@certusone/wormhole-sdk";
export const chainIDStrings: { [key in ChainId]: string } = {
1: "Solana",
2: "Ethereum",
3: "Terra",
4: "BSC",
5: "Polygon",
6: "Avalanche",
7: "Oasis",
8: "Algorand",
9: "Aurora",
10: "Fantom",
11: "Karura",
12: "Acala",
10001: "Ropsten",
};

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"outDir": "lib",
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["es2019"],
"skipLibCheck": true,
"allowJs": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"downlevelIteration": true
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]
}

View File

@ -1,5 +1,17 @@
# Changelog
## 0.2.3
### Added
Expose feeRecipientAddress for redeemOnSolana
## 0.2.2
### Added
Include fee in parseTransferPayload
## 0.2.1
### Added

View File

@ -1,6 +1,6 @@
{
"name": "@certusone/wormhole-sdk",
"version": "0.2.1",
"version": "0.2.3",
"description": "SDK for interacting with Wormhole",
"homepage": "https://wormholenetwork.com",
"main": "./lib/cjs/index.js",

View File

@ -150,7 +150,8 @@ export async function redeemOnSolana(
bridgeAddress: string,
tokenBridgeAddress: string,
payerAddress: string,
signedVAA: Uint8Array
signedVAA: Uint8Array,
feeRecipientAddress?: string
) {
const { parse_vaa } = await importCoreWasm();
const parsedVAA = parse_vaa(signedVAA);
@ -167,7 +168,8 @@ export async function redeemOnSolana(
tokenBridgeAddress,
bridgeAddress,
payerAddress,
signedVAA
signedVAA,
feeRecipientAddress
)
)
);
@ -178,7 +180,8 @@ export async function redeemOnSolana(
tokenBridgeAddress,
bridgeAddress,
payerAddress,
signedVAA
signedVAA,
feeRecipientAddress
)
)
);

View File

@ -56,6 +56,7 @@ export const parseTransferPayload = (arr: Buffer) => ({
originChain: arr.readUInt16BE(65) as ChainId,
targetAddress: arr.slice(67, 67 + 32).toString("hex"),
targetChain: arr.readUInt16BE(99) as ChainId,
fee: BigNumber.from(arr.slice(101, 101 + 32)).toBigInt(),
});
//This returns a corrected amount, which accounts for the difference between the VAA

108
third_party/redis/Dockerfile vendored Normal file
View File

@ -0,0 +1,108 @@
FROM alpine:3.14
# add our user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added
RUN addgroup -S -g 1000 redis && adduser -S -G redis -u 999 redis
# alpine already has a gid 999, so we'll use the next id
RUN apk add --no-cache \
# grab su-exec for easy step-down from root
'su-exec>=0.2' \
# add tzdata for https://github.com/docker-library/redis/issues/138
tzdata
ENV REDIS_VERSION 6.2.6
ENV REDIS_DOWNLOAD_URL http://download.redis.io/releases/redis-6.2.6.tar.gz
ENV REDIS_DOWNLOAD_SHA 5b2b8b7a50111ef395bf1c1d5be11e6e167ac018125055daa8b5c2317ae131ab
RUN set -eux; \
\
apk add --no-cache --virtual .build-deps \
coreutils \
dpkg-dev dpkg \
gcc \
linux-headers \
make \
musl-dev \
openssl-dev \
# install real "wget" to avoid:
# + wget -O redis.tar.gz https://download.redis.io/releases/redis-6.0.6.tar.gz
# Connecting to download.redis.io (45.60.121.1:80)
# wget: bad header line: XxhODalH: btu; path=/; Max-Age=900
wget \
; \
\
wget -O redis.tar.gz "$REDIS_DOWNLOAD_URL"; \
echo "$REDIS_DOWNLOAD_SHA *redis.tar.gz" | sha256sum -c -; \
mkdir -p /usr/src/redis; \
tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1; \
rm redis.tar.gz; \
\
# disable Redis protected mode [1] as it is unnecessary in context of Docker
# (ports are not automatically exposed when running inside Docker, but rather explicitly by specifying -p / -P)
# [1]: https://github.com/redis/redis/commit/edd4d555df57dc84265fdfb4ef59a4678832f6da
grep -E '^ *createBoolConfig[(]"protected-mode",.*, *1 *,.*[)],$' /usr/src/redis/src/config.c; \
sed -ri 's!^( *createBoolConfig[(]"protected-mode",.*, *)1( *,.*[)],)$!\10\2!' /usr/src/redis/src/config.c; \
grep -E '^ *createBoolConfig[(]"protected-mode",.*, *0 *,.*[)],$' /usr/src/redis/src/config.c; \
# for future reference, we modify this directly in the source instead of just supplying a default configuration flag because apparently "if you specify any argument to redis-server, [it assumes] you are going to specify everything"
# see also https://github.com/docker-library/redis/issues/4#issuecomment-50780840
# (more exactly, this makes sure the default behavior of "save on SIGTERM" stays functional by default)
\
# https://github.com/jemalloc/jemalloc/issues/467 -- we need to patch the "./configure" for the bundled jemalloc to match how Debian compiles, for compatibility
# (also, we do cross-builds, so we need to embed the appropriate "--build=xxx" values to that "./configure" invocation)
gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \
extraJemallocConfigureFlags="--build=$gnuArch"; \
# https://salsa.debian.org/debian/jemalloc/-/blob/c0a88c37a551be7d12e4863435365c9a6a51525f/debian/rules#L8-23
dpkgArch="$(dpkg --print-architecture)"; \
case "${dpkgArch##*-}" in \
amd64 | i386 | x32) extraJemallocConfigureFlags="$extraJemallocConfigureFlags --with-lg-page=12" ;; \
*) extraJemallocConfigureFlags="$extraJemallocConfigureFlags --with-lg-page=16" ;; \
esac; \
extraJemallocConfigureFlags="$extraJemallocConfigureFlags --with-lg-hugepage=21"; \
grep -F 'cd jemalloc && ./configure ' /usr/src/redis/deps/Makefile; \
sed -ri 's!cd jemalloc && ./configure !&'"$extraJemallocConfigureFlags"' !' /usr/src/redis/deps/Makefile; \
grep -F "cd jemalloc && ./configure $extraJemallocConfigureFlags " /usr/src/redis/deps/Makefile; \
\
export BUILD_TLS=yes; \
make -C /usr/src/redis -j "$(nproc)" all; \
make -C /usr/src/redis install; \
\
# TODO https://github.com/redis/redis/pull/3494 (deduplicate "redis-server" copies)
serverMd5="$(md5sum /usr/local/bin/redis-server | cut -d' ' -f1)"; export serverMd5; \
find /usr/local/bin/redis* -maxdepth 0 \
-type f -not -name redis-server \
-exec sh -eux -c ' \
md5="$(md5sum "$1" | cut -d" " -f1)"; \
test "$md5" = "$serverMd5"; \
' -- '{}' ';' \
-exec ln -svfT 'redis-server' '{}' ';' \
; \
\
rm -r /usr/src/redis; \
\
runDeps="$( \
scanelf --needed --nobanner --format '%n#p' --recursive /usr/local \
| tr ',' '\n' \
| sort -u \
| awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
)"; \
apk add --no-network --virtual .redis-rundeps $runDeps; \
apk del --no-network .build-deps; \
\
redis-cli --version; \
redis-server --version
RUN mkdir /data && chown redis:redis /data
VOLUME /data
WORKDIR /data
ADD . .
RUN chmod 777 /data/third_party/redis/docker-entrypoint.sh
RUN cp /data/third_party/redis/docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 6379
CMD ["redis-server"]
#CMD nc -lkp 2000 0.0.0.0

16
third_party/redis/docker-entrypoint.sh vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
set -e
# first arg is `-f` or `--some-option`
# or first arg is `something.conf`
if [ "${1#-}" != "$1" ] || [ "${1%.conf}" != "$1" ]; then
set -- redis-server "$@"
fi
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
find . \! -user redis -exec chown redis '{}' +
exec su-exec redis "$0" "$@"
fi
exec "$@"