diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json index d8895a2e..d347202a 100644 --- a/sdk/js/package-lock.json +++ b/sdk/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@certusone/wormhole-sdk", - "version": "0.0.8", + "version": "0.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@certusone/wormhole-sdk", - "version": "0.0.8", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "@improbable-eng/grpc-web": "^0.14.0", @@ -14,6 +14,7 @@ "@solana/web3.js": "^1.24.0", "@terra-money/terra.js": "^2.0.14", "@terra-money/wallet-provider": "^2.2.0", + "axios": "^0.24.0", "bech32": "^2.0.0", "js-base64": "^3.6.1", "protobufjs": "^6.11.2", @@ -2370,6 +2371,14 @@ "node": ">=12" } }, + "node_modules/@terra-money/terra.js/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/@terra-money/wallet-provider": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@terra-money/wallet-provider/-/wallet-provider-2.2.0.tgz", @@ -2948,11 +2957,11 @@ "dev": true }, "node_modules/axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", "dependencies": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.4" } }, "node_modules/babel-jest": { @@ -4201,9 +4210,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.2.tgz", - "integrity": "sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==", "funding": [ { "type": "individual", @@ -10224,6 +10233,16 @@ "tmp": "^0.2.1", "utf-8-validate": "^5.0.5", "ws": "^7.4.2" + }, + "dependencies": { + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + } } }, "@terra-money/wallet-provider": { @@ -10736,11 +10755,11 @@ "dev": true }, "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", "requires": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.4" } }, "babel-jest": { @@ -11741,9 +11760,9 @@ } }, "follow-redirects": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.2.tgz", - "integrity": "sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==" + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" }, "form-data": { "version": "3.0.1", diff --git a/sdk/js/package.json b/sdk/js/package.json index bf310f59..46190edf 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -60,6 +60,7 @@ "@solana/web3.js": "^1.24.0", "@terra-money/terra.js": "^2.0.14", "@terra-money/wallet-provider": "^2.2.0", + "axios": "^0.24.0", "bech32": "^2.0.0", "js-base64": "^3.6.1", "protobufjs": "^6.11.2", diff --git a/sdk/js/src/bridge/getSignedVAAHash.ts b/sdk/js/src/bridge/getSignedVAAHash.ts new file mode 100644 index 00000000..10a3ad1e --- /dev/null +++ b/sdk/js/src/bridge/getSignedVAAHash.ts @@ -0,0 +1,18 @@ +import { importCoreWasm } from "../solana/wasm"; +import { ethers } from "ethers"; +import { uint8ArrayToHex } from ".."; + +export async function getSignedVAAHash(signedVAA: Uint8Array) { + const { parse_vaa } = await importCoreWasm(); + const parsedVAA = parse_vaa(signedVAA); + const body = [ + ethers.utils.defaultAbiCoder.encode(["uint32"], [parsedVAA.timestamp]).substring(2 + (64 - 8)), + ethers.utils.defaultAbiCoder.encode(["uint32"], [parsedVAA.nonce]).substring(2 + (64 - 8)), + ethers.utils.defaultAbiCoder.encode(["uint16"], [parsedVAA.emitter_chain]).substring(2 + (64 - 4)), + ethers.utils.defaultAbiCoder.encode(["bytes32"], [parsedVAA.emitter_address]).substring(2), + ethers.utils.defaultAbiCoder.encode(["uint64"], [parsedVAA.sequence]).substring(2 + (64 - 16)), + ethers.utils.defaultAbiCoder.encode(["uint8"], [parsedVAA.consistency_level]).substring(2 + (64 - 2)), + uint8ArrayToHex(parsedVAA.payload), + ]; + return ethers.utils.solidityKeccak256(["bytes"], [ethers.utils.solidityKeccak256(["bytes"], ["0x" + body.join("")])]); +} diff --git a/sdk/js/src/bridge/index.ts b/sdk/js/src/bridge/index.ts index 430fd54c..e9ddb3bf 100644 --- a/sdk/js/src/bridge/index.ts +++ b/sdk/js/src/bridge/index.ts @@ -1,3 +1,4 @@ export * from "./getClaimAddress"; export * from "./getEmitterAddress"; +export * from "./getSignedVAAHash"; export * from "./parseSequenceFromLog"; diff --git a/sdk/js/src/token_bridge/__tests__/consts.ts b/sdk/js/src/token_bridge/__tests__/consts.ts index 5c106a93..5c0c7a39 100644 --- a/sdk/js/src/token_bridge/__tests__/consts.ts +++ b/sdk/js/src/token_bridge/__tests__/consts.ts @@ -20,6 +20,15 @@ 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"]; diff --git a/sdk/js/src/token_bridge/__tests__/integration.ts b/sdk/js/src/token_bridge/__tests__/integration.ts index 6147dc77..4d5366bf 100644 --- a/sdk/js/src/token_bridge/__tests__/integration.ts +++ b/sdk/js/src/token_bridge/__tests__/integration.ts @@ -1,12 +1,14 @@ import { parseUnits } from "@ethersproject/units"; import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport"; -import { describe, jest, test } from "@jest/globals"; +import { describe, expect, jest, test } from "@jest/globals"; 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 { ethers } from "ethers"; import { approveEth, @@ -14,11 +16,16 @@ import { attestFromSolana, CHAIN_ID_ETH, CHAIN_ID_SOLANA, + CHAIN_ID_TERRA, createWrappedOnEth, createWrappedOnSolana, + createWrappedOnTerra, getEmitterAddressEth, getEmitterAddressSolana, getForeignAssetSolana, + getIsTransferCompletedEth, + getIsTransferCompletedSolana, + getIsTransferCompletedTerra, hexToUint8Array, nativeToHexString, parseSequenceFromLogEth, @@ -26,6 +33,7 @@ import { postVaaSolana, redeemOnEth, redeemOnSolana, + redeemOnTerra, transferFromEth, transferFromSolana, } from "../.."; @@ -40,6 +48,11 @@ import { SOLANA_HOST, SOLANA_PRIVATE_KEY, SOLANA_TOKEN_BRIDGE_ADDRESS, + 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, @@ -51,7 +64,6 @@ jest.setTimeout(60000); // TODO: setup keypair and provider/signer before, destroy provider after // TODO: make the repeatable (can't attest an already attested token) -// TODO: add Terra describe("Integration Tests", () => { describe("Ethereum to Solana", () => { @@ -223,6 +235,13 @@ describe("Integration Tests", () => { payerAddress, Buffer.from(signedVAA) ); + expect( + await getIsTransferCompletedSolana( + SOLANA_TOKEN_BRIDGE_ADDRESS, + signedVAA, + connection + ) + ).toBe(false); // redeem tokens on solana const transaction = await redeemOnSolana( connection, @@ -237,6 +256,13 @@ describe("Integration Tests", () => { transaction.serialize() ); await connection.confirmTransaction(txid); + expect( + await getIsTransferCompletedSolana( + SOLANA_TOKEN_BRIDGE_ADDRESS, + signedVAA, + connection + ) + ).toBe(true); provider.destroy(); done(); } catch (e) { @@ -377,7 +403,21 @@ describe("Integration Tests", () => { transport: NodeHttpTransport(), } ); + expect( + await getIsTransferCompletedEth( + ETH_TOKEN_BRIDGE_ADDRESS, + provider, + signedVAA + ) + ).toBe(false); await redeemOnEth(ETH_TOKEN_BRIDGE_ADDRESS, signer, signedVAA); + expect( + await getIsTransferCompletedEth( + ETH_TOKEN_BRIDGE_ADDRESS, + provider, + signedVAA + ) + ).toBe(true); provider.destroy(); done(); } catch (e) { @@ -390,4 +430,178 @@ describe("Integration Tests", () => { }); // TODO: it has increased balance }); + 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 feeEstimate = await lcd.tx.estimateFee( + wallet.key.accAddress, + [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) + 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(), + } + ); + 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"); + } + })(); + }); + // TODO: it has increased balance + }); }); diff --git a/sdk/js/src/token_bridge/getIsTransferCompleted.ts b/sdk/js/src/token_bridge/getIsTransferCompleted.ts new file mode 100644 index 00000000..699f0f5f --- /dev/null +++ b/sdk/js/src/token_bridge/getIsTransferCompleted.ts @@ -0,0 +1,55 @@ +import { ethers } from "ethers"; +import { Bridge__factory } from "../ethers-contracts"; +import { getSignedVAAHash } from "../bridge"; +import { importCoreWasm } from "../solana/wasm"; +import { Connection, PublicKey } from "@solana/web3.js"; +import { LCDClient } from "@terra-money/terra.js"; +import axios from "axios"; +import { redeemOnTerra } from "."; + +export async function getIsTransferCompletedEth( + tokenBridgeAddress: string, + provider: ethers.providers.Provider, + signedVAA: Uint8Array +) { + const tokenBridge = Bridge__factory.connect(tokenBridgeAddress, provider); + const signedVAAHash = await getSignedVAAHash(signedVAA); + return await tokenBridge.isTransferCompleted(signedVAAHash); +} + +export async function getIsTransferCompletedTerra( + tokenBridgeAddress: string, + signedVAA: Uint8Array, + walletAddress: string, + client: LCDClient, + gasPriceUrl: string +) { + const msg = await redeemOnTerra(tokenBridgeAddress, walletAddress, signedVAA); + // TODO: remove gasPriceUrl and just use the client's gas prices + const gasPrices = await axios.get(gasPriceUrl).then((result) => result.data); + try { + await client.tx.estimateFee(walletAddress, [msg], { + memo: "already redeemed calculation", + feeDenoms: ["uluna"], + gasPrices, + }); + } catch (e) { + // redeemed if the VAA was already executed + return e.response.data.error.includes("VaaAlreadyExecuted"); + } + return false; +} + +export async function getIsTransferCompletedSolana( + tokenBridgeAddress: string, + signedVAA: Uint8Array, + connection: Connection +) { + const { claim_address } = await importCoreWasm(); + const claimAddress = await claim_address(tokenBridgeAddress, signedVAA); + const claimInfo = await connection.getAccountInfo( + new PublicKey(claimAddress), + "confirmed" + ); + return !!claimInfo; +} diff --git a/sdk/js/src/token_bridge/index.ts b/sdk/js/src/token_bridge/index.ts index 315596d1..4464344b 100644 --- a/sdk/js/src/token_bridge/index.ts +++ b/sdk/js/src/token_bridge/index.ts @@ -1,6 +1,7 @@ export * from "./attest"; export * from "./createWrapped"; export * from "./getForeignAsset"; +export * from "./getIsTransferCompleted"; export * from "./getIsWrappedAsset"; export * from "./getOriginalAsset"; export * from "./redeem";