瀏覽代碼

sdk/js: Terra NFT bridge support (#699)

commit-id:5a5c9d29
Csongor Kiss 3 年之前
父節點
當前提交
cb7e90a701

+ 2 - 0
sdk/js/CHANGELOG.md

@@ -5,6 +5,8 @@
 ### Added
 
 added parseSequencesFromLog\*
+Terra NFT token bridge
+getIsTransferCompleted on NFT bridge
 
 ## 0.1.5
 

File diff suppressed because it is too large
+ 790 - 10
sdk/js/package-lock.json


+ 2 - 1
sdk/js/package.json

@@ -52,7 +52,8 @@
     "ts-jest": "^27.0.7",
     "tslint": "^6.1.3",
     "tslint-config-prettier": "^1.18.0",
-    "typescript": "^4.3.5"
+    "typescript": "^4.3.5",
+    "web3": "^1.6.1"
   },
   "dependencies": {
     "@improbable-eng/grpc-web": "^0.14.0",

+ 9 - 0
sdk/js/src/index.ts

@@ -5,3 +5,12 @@ export * from "./rpc";
 export * from "./utils";
 export * from "./bridge";
 export * from "./token_bridge";
+
+export * as ethers_contracts from "./ethers-contracts";
+export * as solana from "./solana";
+export * as terra from "./terra";
+export * as rpc from "./rpc";
+export * as utils from "./utils";
+export * as bridge from "./bridge";
+export * as token_bridge from "./token_bridge";
+export * as nft_bridge from "./nft_bridge";

+ 46 - 0
sdk/js/src/nft_bridge/__tests__/consts.ts

@@ -0,0 +1,46 @@
+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 =
+  "0x6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1"; // account 1
+export const ETH_CORE_BRIDGE_ADDRESS =
+  "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550";
+export const ETH_NFT_BRIDGE_ADDRESS =
+  "0x26b4afb60d6c903165150c6f0aa14f8016be4aec";
+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_NFT_BRIDGE_ADDRESS =
+  "NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA";
+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_NFT_BRIDGE_ADDRESS =
+  "terra19zpyd046u4swqpksr3n44cej4j8pg6ah2y6dcg";
+export const TERRA_PRIVATE_KEY =
+  "quality vacuum heart guard buzz spike sight swarm shove special gym robust assume sudden deposit grid alcohol choice devote leader tilt noodle tide penalty";
+export const TEST_ERC721 = "0x5b9b42d6e4B2e4Bf8d42Eba32D46918e10899B66";
+export const TERRA_CW721_CODE_ID = 8;
+export const TEST_CW721 = "terra1l425neayde0fzfcv3apkyk4zqagvflm6cmha9v";
+export const TEST_SOLANA_TOKEN = "BVxyYhm498L79r4HMQ9sxZ5bi41DmJmeWZ7SCS7Cyvna";
+export const WORMHOLE_RPC_HOSTS = ["http://localhost:7071"];
+
+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();
+  });
+});

+ 604 - 0
sdk/js/src/nft_bridge/__tests__/integration.ts

@@ -0,0 +1,604 @@
+import { BlockTxBroadcastResult, Coins, LCDClient, MnemonicKey, Msg, MsgExecuteContract, StdFee, TxInfo, Wallet } from "@terra-money/terra.js";
+import axios from "axios";
+import Web3 from 'web3';
+import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport";
+import { describe, expect, jest, test } from "@jest/globals";
+import {
+  ASSOCIATED_TOKEN_PROGRAM_ID,
+  Token,
+  TOKEN_PROGRAM_ID,
+} from "@solana/spl-token";
+import {
+  MsgInstantiateContract,
+} from "@terra-money/terra.js";
+import { BigNumberish, ethers } from "ethers";
+import {
+  ChainId,
+  CHAIN_ID_ETH,
+  CHAIN_ID_TERRA,
+  getEmitterAddressEth,
+  getEmitterAddressTerra,
+  hexToUint8Array,
+  nativeToHexString,
+  parseSequenceFromLogEth,
+  parseSequenceFromLogTerra,
+  nft_bridge,
+  parseSequenceFromLogSolana,
+  getEmitterAddressSolana,
+  CHAIN_ID_SOLANA,
+  parseNFTPayload
+} from "../..";
+import getSignedVAAWithRetry from "../../rpc/getSignedVAAWithRetry";
+import { importCoreWasm, importNftWasm, setDefaultWasm } from "../../solana/wasm";
+import {
+  ETH_CORE_BRIDGE_ADDRESS,
+  ETH_NODE_URL,
+  ETH_PRIVATE_KEY,
+  ETH_NFT_BRIDGE_ADDRESS,
+  TERRA_GAS_PRICES_URL,
+  TERRA_NFT_BRIDGE_ADDRESS,
+  WORMHOLE_RPC_HOSTS,
+  TERRA_CW721_CODE_ID,
+  TERRA_NODE_URL,
+  TERRA_CHAIN_ID,
+  TERRA_PRIVATE_KEY,
+  SOLANA_PRIVATE_KEY,
+  TEST_SOLANA_TOKEN,
+  SOLANA_HOST,
+  SOLANA_CORE_BRIDGE_ADDRESS,
+  SOLANA_NFT_BRIDGE_ADDRESS,
+} from "./consts";
+import {
+  NFTImplementation,
+  NFTImplementation__factory,
+} from "../../ethers-contracts";
+import sha3 from "js-sha3";
+import { Connection, Keypair, PublicKey, TransactionResponse } from "@solana/web3.js";
+import { postVaaSolanaWithRetry } from "../../solana";
+const ERC721 = require("@openzeppelin/contracts/build/contracts/ERC721PresetMinterPauserAutoId.json");
+
+setDefaultWasm("node");
+
+jest.setTimeout(60000);
+
+type Address = string;
+
+// terra setup
+const lcd = new LCDClient({
+  URL: TERRA_NODE_URL,
+  chainID: TERRA_CHAIN_ID,
+});
+const terraWallet: Wallet = lcd.wallet(new MnemonicKey({
+  mnemonic: TERRA_PRIVATE_KEY,
+}));
+
+// ethereum setup
+const web3 = new Web3(ETH_NODE_URL);
+
+let provider: ethers.providers.WebSocketProvider;
+let signer: ethers.Wallet;
+
+// solana setup
+const connection = new Connection(SOLANA_HOST, "confirmed");
+
+const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
+const payerAddress = keypair.publicKey.toString();
+
+beforeEach(() => {
+  provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
+  signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider); // corresponds to accounts[1]
+})
+
+afterEach(() => {
+  provider.destroy();
+})
+
+describe("Integration Tests", () => {
+  test("Send Ethereum ERC-721 to Terra and back", (done) => {
+    (async () => {
+      try {
+        const erc721 = await deployNFTOnEth(
+          "Not an APE 🐒",
+          "APE🐒",
+          "https://cloudflare-ipfs.com/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/",
+          11 // mint ids 0..10 (inclusive)
+        );
+        const transaction = await _transferFromEth(erc721.address, 10, terraWallet.key.accAddress, CHAIN_ID_TERRA);
+        let signedVAA = await waitUntilEthTxObserved(transaction);
+        (await expectReceivedOnTerra(signedVAA)).toBe(false);
+        await _redeemOnTerra(signedVAA);
+        (await expectReceivedOnTerra(signedVAA)).toBe(true);
+
+        // Check we have the wrapped NFT contract
+        const terra_addr = await nft_bridge.getForeignAssetTerra(TERRA_NFT_BRIDGE_ADDRESS, lcd, CHAIN_ID_ETH,
+          hexToUint8Array(
+            nativeToHexString(erc721.address, CHAIN_ID_ETH) || ""
+          ));
+        if (!terra_addr) {
+          throw new Error("Terra address is null");
+        }
+
+        // 10 => "10"
+        const info: any = await lcd.wasm.contractQuery(terra_addr, { nft_info: { token_id: "10" } });
+        expect(info.token_uri).toBe("https://cloudflare-ipfs.com/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/10");
+
+        const ownerInfo: any = await lcd.wasm.contractQuery(terra_addr, { owner_of: { token_id: "10" } });
+        expect(ownerInfo.owner).toBe(terraWallet.key.accAddress);
+
+        let ownerEth = await erc721.ownerOf(10);
+        expect(ownerEth).not.toBe(signer.address);
+
+        // Send back the NFT to ethereum
+        const transaction2 = await _transferFromTerra(terra_addr, "10", signer.address, CHAIN_ID_ETH);
+        signedVAA = await waitUntilTerraTxObserved(transaction2);
+        (await expectReceivedOnEth(signedVAA)).toBe(false);
+        await _redeemOnEth(signedVAA);
+        (await expectReceivedOnEth(signedVAA)).toBe(true);
+
+        // ensure that the transaction roundtrips back to the original native asset
+        ownerEth = await erc721.ownerOf(10);
+        expect(ownerEth).toBe(signer.address);
+
+        // the wrapped token should no longer exist
+        let error;
+        try {
+          await lcd.wasm.contractQuery(terra_addr, { owner_of: { token_id: "10" } });
+        } catch (e) {
+          error = e;
+        }
+        expect(error).not.toBeNull();
+
+        done();
+      } catch (e) {
+        console.error(e);
+        done(`An error occured while trying to transfer from Ethereum to Terra and back: ${e}`);
+      }
+    })();
+  });
+  test("Send Terra CW721 to Ethereum and back", (done) => {
+    (async () => {
+      try {
+        const token_id = "first";
+        const cw721 = await deployNFTOnTerra(
+          "Integration Test NFT",
+          "INT",
+          'https://ixmfkhnh2o4keek2457f2v2iw47cugsx23eynlcfpstxihsay7nq.arweave.net/RdhVHafTuKIRWud-XVdItz4qGlfWyYasRXyndB5Ax9s/',
+          token_id
+        );
+        // transfer NFT
+        const transaction = await _transferFromTerra(cw721, token_id, signer.address, CHAIN_ID_ETH);
+        let signedVAA = await waitUntilTerraTxObserved(transaction);
+        (await expectReceivedOnEth(signedVAA)).toBe(false);
+        await _redeemOnEth(signedVAA);
+        (await expectReceivedOnEth(signedVAA)).toBe(true);
+
+        // Check we have the wrapped NFT contract
+        const eth_addr = await nft_bridge.getForeignAssetEth(ETH_NFT_BRIDGE_ADDRESS, provider, CHAIN_ID_TERRA,
+          hexToUint8Array(
+            nativeToHexString(cw721, CHAIN_ID_TERRA) || ""
+          ));
+        if (!eth_addr) {
+          throw new Error("Ethereum address is null");
+        }
+
+        const token = NFTImplementation__factory.connect(eth_addr, signer);
+        // the id on eth will be the keccak256 hash of the terra id
+        const eth_id = '0x' + sha3.keccak256(token_id);
+        const owner = await token.ownerOf(eth_id);
+        expect(owner).toBe(signer.address);
+
+        // send back the NFT to terra
+        const transaction2 = await _transferFromEth(eth_addr, eth_id, terraWallet.key.accAddress, CHAIN_ID_TERRA);
+        signedVAA = await waitUntilEthTxObserved(transaction2);
+        (await expectReceivedOnTerra(signedVAA)).toBe(false);
+        await _redeemOnTerra(signedVAA);
+        (await expectReceivedOnTerra(signedVAA)).toBe(true);
+
+        const ownerInfo: any = await lcd.wasm.contractQuery(cw721, { owner_of: { token_id: token_id } });
+        expect(ownerInfo.owner).toBe(terraWallet.key.accAddress);
+
+        // the wrapped token should no longer exist
+        let error;
+        try {
+          await token.ownerOf(eth_id);
+        } catch (e) {
+          error = e;
+        }
+        expect(error).not.toBeNull();
+        expect(error.message).toContain("nonexistent token");
+
+        done();
+      } catch (e) {
+        console.error(e);
+        done(`An error occured while trying to transfer from Terra to Ethereum: ${e}`);
+      }
+    })();
+  });
+  test("Send Solana SPL to Terra to Etheretum to Solana", (done) => {
+    (async () => {
+      try {
+        const { parse_vaa } = await importCoreWasm();
+
+        const fromAddress = (
+          await Token.getAssociatedTokenAddress(
+            ASSOCIATED_TOKEN_PROGRAM_ID,
+            TOKEN_PROGRAM_ID,
+            new PublicKey(TEST_SOLANA_TOKEN),
+            keypair.publicKey
+          )
+        );
+
+        // send to terra
+        const transaction1 = await _transferFromSolana(fromAddress, terraWallet.key.accAddress, CHAIN_ID_TERRA);
+        let signedVAA = await waitUntilSolanaTxObserved(transaction1);
+
+        // we get the solana token id from the VAA
+        const { tokenId } = parseNFTPayload(
+          Buffer.from(new Uint8Array(parse_vaa(signedVAA).payload))
+        );
+
+        await _redeemOnTerra(signedVAA);
+        const terra_addr = await nft_bridge.getForeignAssetTerra(TERRA_NFT_BRIDGE_ADDRESS, lcd, CHAIN_ID_SOLANA,
+          hexToUint8Array(
+            nativeToHexString(TEST_SOLANA_TOKEN, CHAIN_ID_SOLANA) || ""
+          ));
+        if (!terra_addr) {
+          throw new Error("Terra address is null");
+        }
+        // send to ethereum
+        const transaction2 = await _transferFromTerra(terra_addr, tokenId.toString(), signer.address, CHAIN_ID_ETH);
+        signedVAA = await waitUntilTerraTxObserved(transaction2);
+
+        await _redeemOnEth(signedVAA);
+        const eth_addr = await nft_bridge.getForeignAssetEth(ETH_NFT_BRIDGE_ADDRESS, provider, CHAIN_ID_SOLANA,
+          hexToUint8Array(
+            nativeToHexString(TEST_SOLANA_TOKEN, CHAIN_ID_SOLANA) || ""
+          ));
+        if (!eth_addr) {
+          throw new Error("Ethereum address is null");
+        }
+
+        const transaction3 = await _transferFromEth(eth_addr, tokenId, fromAddress.toString(), CHAIN_ID_SOLANA);
+        signedVAA = await waitUntilEthTxObserved(transaction3);
+
+        const { name, symbol } = parseNFTPayload(
+          Buffer.from(new Uint8Array(parse_vaa(signedVAA).payload))
+        );
+
+        // if the names match up here, it means all the spl caches work
+        expect(name).toBe('Not a PUNK🎸');
+        expect(symbol).toBe('PUNK🎸');
+
+        await _redeemOnSolana(signedVAA);
+
+        done();
+      } catch (e) {
+        console.error(e);
+        done(`An error occured while trying to transfer from Solana to Ethereum: ${e}`);
+      }
+    })();
+  });
+  test("Handles invalid utf8", (done) => {
+    (async () => {
+      const erc721 = await deployNFTOnEth(
+        // 31 bytes of valid characters + a 3 byte character
+        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaࠀ",
+        "test",
+        "https://foo.com",
+        1
+      );
+      const transaction = await _transferFromEth(erc721.address, 0, terraWallet.key.accAddress, CHAIN_ID_TERRA);
+      let signedVAA = await waitUntilEthTxObserved(transaction);
+      await _redeemOnTerra(signedVAA);
+      const terra_addr = await nft_bridge.getForeignAssetTerra(TERRA_NFT_BRIDGE_ADDRESS, lcd, CHAIN_ID_ETH,
+        hexToUint8Array(
+          nativeToHexString(erc721.address, CHAIN_ID_ETH) || ""
+        ));
+      if (!terra_addr) {
+        throw new Error("Terra address is null");
+      }
+      const info: any = await lcd.wasm.contractQuery(terra_addr, { contract_info: {} });
+      expect(info.name).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa�");
+      done();
+    })();
+  });
+});
+
+////////////////////////////////////////////////////////////////////////////////
+// Utils
+
+async function deployNFTOnEth(name: string, symbol: string, uri: string, how_many: number): Promise<NFTImplementation> {
+  const accounts = await web3.eth.getAccounts();
+  const nftContract = new web3.eth.Contract(ERC721.abi);
+  let nft = await nftContract.deploy({
+    data: ERC721.bytecode,
+    arguments: [name, symbol, uri]
+  }).send({
+    from: accounts[1],
+    gas: 5000000,
+  });
+
+  // The eth contracts mints tokens with sequential ids, so in order to get to a
+  // specific id, we need to mint multiple nfts. We need this to test that
+  // foreign ids on terra get converted to the decimal stringified form of the
+  // original id.
+  for (var i = 0; i < how_many; i++) {
+    await nft.methods.mint(accounts[1]).send({
+      from: accounts[1],
+      gas: 1000000,
+    });
+  }
+
+  return NFTImplementation__factory.connect(nft.options.address, signer);
+}
+
+async function deployNFTOnTerra(name: string, symbol: string, uri: string, token_id: string): Promise<Address> {
+  var address: any;
+  await terraWallet
+    .createAndSignTx({
+      msgs: [
+        new MsgInstantiateContract(
+          terraWallet.key.accAddress,
+          terraWallet.key.accAddress,
+          TERRA_CW721_CODE_ID,
+          {
+            name,
+            symbol,
+            minter: terraWallet.key.accAddress,
+          }
+        ),
+      ],
+      memo: "",
+    })
+    .then((tx) => lcd.tx.broadcast(tx))
+    .then((rs) => {
+      const match = /"contract_address","value":"([^"]+)/gm.exec(rs.raw_log);
+      if (match) {
+        address = match[1];
+      }
+    });
+  await mint_cw721(address, token_id, uri);
+  return address;
+}
+
+async function getGasPrices() {
+  return axios
+    .get(TERRA_GAS_PRICES_URL)
+    .then((result) => result.data);
+}
+
+async function estimateTerraFee(gasPrices: Coins.Input, msgs: Msg[]): Promise<StdFee> {
+  const feeEstimate = await lcd.tx.estimateFee(
+    terraWallet.key.accAddress,
+    msgs,
+    {
+      memo: "localhost",
+      feeDenoms: ["uluna"],
+      gasPrices,
+    }
+  );
+  return feeEstimate;
+}
+
+async function waitUntilTerraTxObserved(txresult: BlockTxBroadcastResult): Promise<Uint8Array> {
+  // get the sequence from the logs (needed to fetch the vaa)
+  const info = await waitForTerraExecution(txresult.txhash);
+  const sequence = parseSequenceFromLogTerra(info);
+  const emitterAddress = await getEmitterAddressTerra(TERRA_NFT_BRIDGE_ADDRESS);
+  // poll until the guardian(s) witness and sign the vaa
+  const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
+    WORMHOLE_RPC_HOSTS,
+    CHAIN_ID_TERRA,
+    emitterAddress,
+    sequence,
+    {
+      transport: NodeHttpTransport(),
+    }
+  );
+  return signedVAA;
+}
+
+async function waitUntilEthTxObserved(receipt: ethers.ContractReceipt): Promise<Uint8Array> {
+  // get the sequence from the logs (needed to fetch the vaa)
+  let sequence = parseSequenceFromLogEth(
+    receipt,
+    ETH_CORE_BRIDGE_ADDRESS
+  );
+  let emitterAddress = getEmitterAddressEth(ETH_NFT_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(),
+    }
+  );
+  return signedVAA;
+}
+
+async function waitUntilSolanaTxObserved(response: TransactionResponse): Promise<Uint8Array> {
+  // get the sequence from the logs (needed to fetch the vaa)
+  const sequence = parseSequenceFromLogSolana(response);
+  const emitterAddress = await getEmitterAddressSolana(
+    SOLANA_NFT_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(),
+    }
+  );
+  return signedVAA;
+}
+
+async function mint_cw721(contract_address: string, token_id: string, token_uri: any): Promise<void> {
+  await terraWallet
+    .createAndSignTx({
+      msgs: [
+        new MsgExecuteContract(
+          terraWallet.key.accAddress,
+          contract_address,
+          {
+            mint: {
+              token_id,
+              owner: terraWallet.key.accAddress,
+              token_uri: token_uri,
+            },
+          },
+          { uluna: 1000 }
+        ),
+      ],
+      memo: "",
+      fee: new StdFee(2000000, {
+        uluna: "100000",
+      }),
+    })
+    .then((tx) => lcd.tx.broadcast(tx));
+}
+
+async function waitForTerraExecution(txHash: string): Promise<TxInfo> {
+  let info: TxInfo | undefined = undefined;
+  while (!info) {
+    await new Promise((resolve) => setTimeout(resolve, 1000));
+    try {
+      info = await lcd.tx.txInfo(txHash);
+    } catch (e) {
+      console.error(e);
+    }
+  }
+  if (info.code !== undefined) {
+    // error code
+    throw new Error(
+      `Tx ${txHash}: error code ${info.code}: ${info.raw_log}`
+    );
+  }
+  return info;
+}
+
+async function expectReceivedOnTerra(signedVAA: Uint8Array) {
+  return expect(
+    await nft_bridge.getIsTransferCompletedTerra(
+      TERRA_NFT_BRIDGE_ADDRESS,
+      signedVAA,
+      terraWallet.key.accAddress,
+      lcd,
+      TERRA_GAS_PRICES_URL
+    )
+  );
+}
+
+async function expectReceivedOnEth(signedVAA: Uint8Array) {
+  return expect(
+    await nft_bridge.getIsTransferCompletedEth(
+      ETH_NFT_BRIDGE_ADDRESS,
+      provider,
+      signedVAA,
+    )
+  );
+}
+
+async function _transferFromEth(erc721: string, token_id: BigNumberish, address: string, chain: ChainId): Promise<ethers.ContractReceipt> {
+  return nft_bridge.transferFromEth(
+    ETH_NFT_BRIDGE_ADDRESS,
+    signer,
+    erc721,
+    token_id,
+    chain,
+    hexToUint8Array(
+      nativeToHexString(address, chain) || ""
+    ));
+}
+
+async function _transferFromTerra(terra_addr: string, token_id: string, address: string, chain: ChainId): Promise<BlockTxBroadcastResult> {
+  const gasPrices = await getGasPrices();
+  const msgs = await nft_bridge.transferFromTerra(
+    terraWallet.key.accAddress,
+    TERRA_NFT_BRIDGE_ADDRESS,
+    terra_addr,
+    token_id,
+    chain,
+    hexToUint8Array(nativeToHexString(address, chain) || ""));
+  const tx = await terraWallet.createAndSignTx({
+    msgs: msgs,
+    memo: "test",
+    feeDenoms: ["uluna"],
+    gasPrices,
+    fee: await estimateTerraFee(gasPrices, msgs)
+  });
+  return lcd.tx.broadcast(tx);
+}
+
+async function _transferFromSolana(fromAddress: PublicKey, targetAddress: string, chain: ChainId): Promise<TransactionResponse> {
+  const transaction = await nft_bridge.transferFromSolana(
+    connection,
+    SOLANA_CORE_BRIDGE_ADDRESS,
+    SOLANA_NFT_BRIDGE_ADDRESS,
+    payerAddress,
+    fromAddress.toString(),
+    TEST_SOLANA_TOKEN,
+    hexToUint8Array(
+      nativeToHexString(targetAddress, chain) || ""
+    ),
+    chain
+  );
+  // 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"
+    );
+  }
+  return info;
+}
+
+
+async function _redeemOnEth(signedVAA: Uint8Array): Promise<ethers.ContractReceipt> {
+  return nft_bridge.redeemOnEth(
+    ETH_NFT_BRIDGE_ADDRESS,
+    signer,
+    signedVAA
+  );
+}
+
+async function _redeemOnTerra(signedVAA: Uint8Array): Promise<BlockTxBroadcastResult> {
+  const msg = await nft_bridge.redeemOnTerra(
+    TERRA_NFT_BRIDGE_ADDRESS,
+    terraWallet.key.accAddress,
+    signedVAA
+  );
+  const gasPrices = await getGasPrices();
+  const tx = await terraWallet.createAndSignTx({
+    msgs: [msg],
+    memo: "localhost",
+    feeDenoms: ["uluna"],
+    gasPrices,
+    fee: await estimateTerraFee(gasPrices, [msg]),
+  });
+  return lcd.tx.broadcast(tx);
+}
+
+async function _redeemOnSolana(signedVAA: Uint8Array) {
+  const maxRetries = 5;
+  await postVaaSolanaWithRetry(
+    connection,
+    async (transaction) => {
+      transaction.partialSign(keypair);
+      return transaction;
+    },
+    SOLANA_CORE_BRIDGE_ADDRESS,
+    payerAddress,
+    Buffer.from(signedVAA),
+    maxRetries
+  )
+}

+ 39 - 3
sdk/js/src/nft_bridge/getForeignAsset.ts

@@ -1,5 +1,7 @@
 import { PublicKey } from "@solana/web3.js";
+import { LCDClient } from "@terra-money/terra.js";
 import { ethers } from "ethers";
+import { fromUint8Array } from "js-base64";
 import { CHAIN_ID_SOLANA } from "..";
 import { NFTBridge__factory } from "../ethers-contracts";
 import { importNftWasm } from "../solana/wasm";
@@ -15,10 +17,10 @@ import { ChainId } from "../utils";
  */
 export async function getForeignAssetEth(
   tokenBridgeAddress: string,
-  provider: ethers.providers.Web3Provider,
+  provider: ethers.Signer | ethers.providers.Provider,
   originChain: ChainId,
   originAsset: Uint8Array
-) {
+): Promise<string | null> {
   const tokenBridge = NFTBridge__factory.connect(tokenBridgeAddress, provider);
   try {
     if (originChain === CHAIN_ID_SOLANA) {
@@ -35,6 +37,40 @@ export async function getForeignAssetEth(
     return null;
   }
 }
+
+
+/**
+ * Returns a foreign asset address on Terra for a provided native chain and asset address
+ * @param tokenBridgeAddress
+ * @param client
+ * @param originChain
+ * @param originAsset
+ * @returns
+ */
+export async function getForeignAssetTerra(
+  tokenBridgeAddress: string,
+  client: LCDClient,
+  originChain: ChainId,
+  originAsset: Uint8Array,
+): Promise<string | null> {
+  try {
+    const address =
+      originChain == CHAIN_ID_SOLANA ? "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" : fromUint8Array(originAsset);
+    const result: { address: string } = await client.wasm.contractQuery(
+      tokenBridgeAddress,
+      {
+        wrapped_registry: {
+          chain: originChain,
+          address,
+        },
+      }
+    );
+    return result.address;
+  } catch (e) {
+    return null;
+  }
+}
+
 /**
  * Returns a foreign asset address on Solana for a provided native chain and asset address
  * @param tokenBridgeAddress
@@ -47,7 +83,7 @@ export async function getForeignAssetSol(
   originChain: ChainId,
   originAsset: Uint8Array,
   tokenId: Uint8Array
-) {
+): Promise<string> {
   const { wrapped_address } = await importNftWasm();
   const wrappedAddress = wrapped_address(
     tokenBridgeAddress,

+ 55 - 0
sdk/js/src/nft_bridge/getIsTransferCompleted.ts

@@ -0,0 +1,55 @@
+import { ethers } from "ethers";
+import { NFTBridge__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(
+  nftBridgeAddress: string,
+  provider: ethers.providers.Provider,
+  signedVAA: Uint8Array
+) {
+  const nftBridge = NFTBridge__factory.connect(nftBridgeAddress, provider);
+  const signedVAAHash = await getSignedVAAHash(signedVAA);
+  return await nftBridge.isTransferCompleted(signedVAAHash);
+}
+
+export async function getIsTransferCompletedTerra(
+  nftBridgeAddress: string,
+  signedVAA: Uint8Array,
+  walletAddress: string,
+  client: LCDClient,
+  gasPriceUrl: string
+) {
+  const msg = await redeemOnTerra(nftBridgeAddress, 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(
+  nftBridgeAddress: string,
+  signedVAA: Uint8Array,
+  connection: Connection
+) {
+  const { claim_address } = await importCoreWasm();
+  const claimAddress = await claim_address(nftBridgeAddress, signedVAA);
+  const claimInfo = await connection.getAccountInfo(
+    new PublicKey(claimAddress),
+    "confirmed"
+  );
+  return !!claimInfo;
+}

+ 32 - 1
sdk/js/src/nft_bridge/getOriginalAsset.ts

@@ -1,9 +1,11 @@
 import { Connection, PublicKey } from "@solana/web3.js";
+import { LCDClient } from "@terra-money/terra.js";
 import { BigNumber, ethers } from "ethers";
 import { arrayify, zeroPad } from "ethers/lib/utils";
+import { canonicalAddress, WormholeWrappedInfo } from "..";
 import { TokenImplementation__factory } from "../ethers-contracts";
 import { importNftWasm } from "../solana/wasm";
-import { ChainId, CHAIN_ID_SOLANA } from "../utils";
+import { ChainId, CHAIN_ID_SOLANA, CHAIN_ID_TERRA } from "../utils";
 import { getIsWrappedAssetEth } from "./getIsWrappedAsset";
 
 export interface WormholeWrappedNFTInfo {
@@ -139,3 +141,32 @@ function bigToUint8Array(big: bigint) {
   }
   return u8;
 }
+
+export async function getOriginalAssetTerra(
+  client: LCDClient,
+  wrappedAddress: string
+): Promise<WormholeWrappedInfo> {
+  try {
+    const result: {
+      asset_address: string;
+      asset_chain: ChainId;
+      bridge: string;
+    } = await client.wasm.contractQuery(wrappedAddress, {
+      wrapped_asset_info: {},
+    });
+    if (result) {
+      return {
+        isWrapped: true,
+        chainId: result.asset_chain,
+        assetAddress: new Uint8Array(
+          Buffer.from(result.asset_address, "base64")
+        ),
+      };
+    }
+  } catch (e) {}
+  return {
+    isWrapped: false,
+    chainId: CHAIN_ID_TERRA,
+    assetAddress: zeroPad(canonicalAddress(wrappedAddress), 32),
+  };
+}

+ 1 - 0
sdk/js/src/nft_bridge/index.ts

@@ -1,5 +1,6 @@
 export * from "./getForeignAsset";
 export * from "./getIsWrappedAsset";
+export * from "./getIsTransferCompleted";
 export * from "./getOriginalAsset";
 export * from "./redeem";
 export * from "./transfer";

+ 18 - 4
sdk/js/src/nft_bridge/redeem.ts

@@ -1,5 +1,7 @@
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
+import { MsgExecuteContract } from "@terra-money/terra.js";
 import { ethers } from "ethers";
+import { fromUint8Array } from "js-base64";
 import { CHAIN_ID_SOLANA } from "..";
 import { Bridge__factory } from "../ethers-contracts";
 import { ixFromRust } from "../solana";
@@ -9,14 +11,14 @@ export async function redeemOnEth(
   tokenBridgeAddress: string,
   signer: ethers.Signer,
   signedVAA: Uint8Array
-) {
+): Promise<ethers.ContractReceipt> {
   const bridge = Bridge__factory.connect(tokenBridgeAddress, signer);
   const v = await bridge.completeTransfer(signedVAA);
   const receipt = await v.wait();
   return receipt;
 }
 
-export async function isNFTVAASolanaNative(signedVAA: Uint8Array) {
+export async function isNFTVAASolanaNative(signedVAA: Uint8Array): Promise<boolean> {
   const { parse_vaa } = await importCoreWasm();
   const parsedVAA = parse_vaa(signedVAA);
   const isSolanaNative =
@@ -31,7 +33,7 @@ export async function redeemOnSolana(
   tokenBridgeAddress: string,
   payerAddress: string,
   signedVAA: Uint8Array
-) {
+): Promise<Transaction> {
   const isSolanaNative = await isNFTVAASolanaNative(signedVAA);
   const { complete_transfer_wrapped_ix, complete_transfer_native_ix } =
     await importNftWasm();
@@ -74,7 +76,7 @@ export async function createMetaOnSolana(
   tokenBridgeAddress: string,
   payerAddress: string,
   signedVAA: Uint8Array
-) {
+): Promise<Transaction> {
   const { complete_transfer_wrapped_meta_ix } = await importNftWasm();
   const ix = ixFromRust(
     complete_transfer_wrapped_meta_ix(
@@ -90,3 +92,15 @@ export async function createMetaOnSolana(
   transaction.feePayer = new PublicKey(payerAddress);
   return transaction;
 }
+
+export async function redeemOnTerra(
+  tokenBridgeAddress: string,
+  walletAddress: string,
+  signedVAA: Uint8Array
+): Promise<MsgExecuteContract> {
+  return new MsgExecuteContract(walletAddress, tokenBridgeAddress, {
+    submit_vaa: {
+      data: fromUint8Array(signedVAA),
+    },
+  });
+}

+ 41 - 2
sdk/js/src/nft_bridge/transfer.ts

@@ -1,5 +1,6 @@
 import { Token, TOKEN_PROGRAM_ID } from "@solana/spl-token";
 import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
+import { MsgExecuteContract } from "@terra-money/terra.js";
 import { ethers } from "ethers";
 import {
   NFTBridge__factory,
@@ -16,7 +17,7 @@ export async function transferFromEth(
   tokenID: ethers.BigNumberish,
   recipientChain: ChainId,
   recipientAddress: Uint8Array
-) {
+): Promise<ethers.ContractReceipt> {
   //TODO: should we check if token attestation exists on the target chain
   const token = NFTImplementation__factory.connect(tokenAddress, signer);
   await (await token.approve(tokenBridgeAddress, tokenID)).wait();
@@ -44,7 +45,7 @@ export async function transferFromSolana(
   originAddress?: Uint8Array,
   originChain?: ChainId,
   originTokenId?: Uint8Array
-) {
+): Promise<Transaction> {
   const nonce = createNonce().readUInt32LE(0);
   const transferIx = await getBridgeFeeIx(
     connection,
@@ -107,3 +108,41 @@ export async function transferFromSolana(
   transaction.partialSign(messageKey);
   return transaction;
 }
+
+export async function transferFromTerra(
+  walletAddress: string,
+  tokenBridgeAddress: string,
+  tokenAddress: string,
+  tokenID: string,
+  recipientChain: ChainId,
+  recipientAddress: Uint8Array
+): Promise<MsgExecuteContract[]> {
+  const nonce = Math.round(Math.random() * 100000);
+  return [
+    new MsgExecuteContract(
+      walletAddress,
+      tokenAddress,
+      {
+        approve: {
+          spender: tokenBridgeAddress,
+          token_id: tokenID,
+        },
+      },
+      {}
+    ),
+    new MsgExecuteContract(
+      walletAddress,
+      tokenBridgeAddress,
+      {
+        initiate_transfer: {
+          contract_addr: tokenAddress,
+          token_id: tokenID,
+          recipient_chain: recipientChain,
+          recipient: Buffer.from(recipientAddress).toString("base64"),
+          nonce: nonce,
+        },
+      },
+      {}
+    ),
+  ];
+}

Some files were not shown because too many files changed in this diff