Browse Source

sdk: add postVaaSolanaWithRetry

Chase Moran 3 years ago
parent
commit
693678ef5d

+ 9 - 0
sdk/js/CHANGELOG.md

@@ -1,5 +1,14 @@
 # Changelog
 
+## 0.1.5
+
+deprecated postVaaSolana
+added postVaaSolanaWithRetry, which will retry transactions which failed during processing.
+added createVerifySignaturesInstructions, createPostVaaInstruction, which allows users to construct the postVaa process for themselves at the instruction level.
+added chunks and sendAndConfirmTransactionsWithRetry as utility functions.
+
+added integration tests for postVaaSolanaWithRetry.
+
 ## 0.1.4
 
 initial AVAX testnet support

+ 1 - 1
sdk/js/package.json

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

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

@@ -1,3 +1,4 @@
 export * from "./getBridgeFeeIx";
 export { postVaa as postVaaSolana } from "./postVaa";
+export { postVaaWithRetry as postVaaSolanaWithRetry } from "./postVaa";
 export * from "./rust";

+ 136 - 1
sdk/js/src/solana/postVaa.ts

@@ -5,10 +5,145 @@ import {
   Transaction,
   TransactionInstruction,
 } from "@solana/web3.js";
+import { chunks } from "..";
+import { sendAndConfirmTransactionsWithRetry } from "../utils/solana";
 import { ixFromRust } from "./rust";
 import { importCoreWasm } from "./wasm";
 
-// is there a better pattern for this?
+export async function postVaaWithRetry(
+  connection: Connection,
+  signTransaction: (transaction: Transaction) => Promise<Transaction>,
+  bridge_id: string,
+  payer: string,
+  vaa: Buffer,
+  maxRetries: number
+) {
+  const unsignedTransactions: Transaction[] = [];
+  const signature_set = Keypair.generate();
+  const instructions = await createVerifySignaturesInstructions(
+    connection,
+    bridge_id,
+    payer,
+    vaa,
+    signature_set
+  );
+  const finalInstruction = await createPostVaaInstruction(
+    bridge_id,
+    payer,
+    vaa,
+    signature_set
+  );
+  if (!finalInstruction) {
+    return Promise.reject("Failed to construct the transaction.");
+  }
+
+  //The verify signatures instructions can be batched into groups of 2 safely,
+  //reducing the total number of transactions.
+  const batchableChunks = chunks(instructions, 2);
+  batchableChunks.forEach((chunk) => {
+    let transaction;
+    if (chunk.length === 1) {
+      transaction = new Transaction().add(chunk[0]);
+    } else {
+      transaction = new Transaction().add(chunk[0], chunk[1]);
+    }
+    unsignedTransactions.push(transaction);
+  });
+
+  //the postVaa instruction can only execute after the verifySignature transactions have
+  //successfully completed.
+  const finalTransaction = new Transaction().add(finalInstruction);
+
+  //The signature_set keypair also needs to sign the verifySignature transactions, thus a wrapper is needed.
+  const partialSignWrapper = (transaction: Transaction) => {
+    transaction.partialSign(signature_set);
+    return signTransaction(transaction);
+  };
+
+  await sendAndConfirmTransactionsWithRetry(
+    connection,
+    partialSignWrapper,
+    payer,
+    unsignedTransactions,
+    maxRetries
+  );
+  //While the signature_set is used to create the final instruction, it doesn't need to sign it.
+  await sendAndConfirmTransactionsWithRetry(
+    connection,
+    signTransaction,
+    payer,
+    [finalTransaction],
+    maxRetries
+  );
+
+  return Promise.resolve();
+}
+
+/*
+This returns an array of instructions required to verify the signatures of a VAA, and upload it to the blockchain.
+signature_set should be a new keypair, and also needs to partial sign the transaction when these instructions are submitted.
+*/
+export async function createVerifySignaturesInstructions(
+  connection: Connection,
+  bridge_id: string,
+  payer: string,
+  vaa: Buffer,
+  signature_set: Keypair
+): Promise<TransactionInstruction[]> {
+  const output: TransactionInstruction[] = [];
+  const { guardian_set_address, parse_guardian_set, verify_signatures_ix } =
+    await importCoreWasm();
+  let bridge_state = await getBridgeState(connection, bridge_id);
+  let guardian_addr = new PublicKey(
+    guardian_set_address(bridge_id, bridge_state.guardian_set_index)
+  );
+  let acc = await connection.getAccountInfo(guardian_addr);
+  if (acc?.data === undefined) {
+    return output;
+  }
+  let guardian_data = parse_guardian_set(new Uint8Array(acc?.data));
+
+  let txs = verify_signatures_ix(
+    bridge_id,
+    payer,
+    bridge_state.guardian_set_index,
+    guardian_data,
+    signature_set.publicKey.toString(),
+    vaa
+  );
+  // Add transfer instruction to transaction
+  for (let tx of txs) {
+    let ixs: Array<TransactionInstruction> = tx.map((v: any) => {
+      return ixFromRust(v);
+    });
+    output.push(ixs[0], ixs[1]);
+  }
+  return output;
+}
+
+/*
+This will return the postVaaInstruction. This should only be executed after the verifySignaturesInstructions have been executed.
+signatureSetKeypair should be the same keypair used for verifySignaturesInstructions, but does not need to partialSign the transaction
+when this instruction is submitted.
+*/
+export async function createPostVaaInstruction(
+  bridge_id: string,
+  payer: string,
+  vaa: Buffer,
+  signatureSetKeypair: Keypair
+): Promise<TransactionInstruction> {
+  const { post_vaa_ix } = await importCoreWasm();
+  return ixFromRust(
+    post_vaa_ix(bridge_id, payer, signatureSetKeypair.publicKey.toString(), vaa)
+  );
+}
+
+/*
+  @deprecated
+  Instead, either use postVaaWithRetry or create, sign, and send the verifySignaturesInstructions & postVaaInstruction yourself.
+  
+  This function is equivalent to a postVaaWithRetry with a maxRetries of 0.
+*/
 export async function postVaa(
   connection: Connection,
   signTransaction: (transaction: Transaction) => Promise<Transaction>,

+ 94 - 0
sdk/js/src/token_bridge/__tests__/helpers.ts

@@ -0,0 +1,94 @@
+import { parseUnits } from "@ethersproject/units";
+import {
+  ASSOCIATED_TOKEN_PROGRAM_ID,
+  Token,
+  TOKEN_PROGRAM_ID,
+} from "@solana/spl-token";
+import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
+import { ethers } from "ethers";
+import {
+  approveEth,
+  CHAIN_ID_ETH,
+  CHAIN_ID_SOLANA,
+  getForeignAssetSolana,
+  hexToUint8Array,
+  nativeToHexString,
+  parseSequenceFromLogEth,
+  transferFromEth,
+} from "../..";
+import {
+  ETH_CORE_BRIDGE_ADDRESS,
+  ETH_NODE_URL,
+  ETH_PRIVATE_KEY,
+  ETH_TOKEN_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOLANA_PRIVATE_KEY,
+  SOLANA_TOKEN_BRIDGE_ADDRESS,
+  TEST_ERC20,
+} from "./consts";
+
+export async function transferFromEthToSolana(): Promise<string> {
+  // create a keypair for Solana
+  const connection = new Connection(SOLANA_HOST, "confirmed");
+  const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
+  // 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)
+  const sequence = await parseSequenceFromLogEth(
+    receipt,
+    ETH_CORE_BRIDGE_ADDRESS
+  );
+  provider.destroy();
+  return sequence;
+}

+ 124 - 0
sdk/js/src/token_bridge/__tests__/integration.ts

@@ -42,6 +42,7 @@ import {
   transferFromSolana,
 } from "../..";
 import getSignedVAAWithRetry from "../../rpc/getSignedVAAWithRetry";
+import { postVaaWithRetry } from "../../solana/postVaa";
 import { setDefaultWasm } from "../../solana/wasm";
 import {
   ETH_CORE_BRIDGE_ADDRESS,
@@ -61,6 +62,7 @@ import {
   TEST_SOLANA_TOKEN,
   WORMHOLE_RPC_HOSTS,
 } from "./consts";
+import { transferFromEthToSolana } from "./helpers";
 
 setDefaultWasm("node");
 
@@ -729,4 +731,126 @@ describe("Integration Tests", () => {
       })();
     });
   });
+  describe("Post VAA with retry", () => {
+    test("postVAA with retry, no failures", (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();
+          const sequence = await transferFromEthToSolana();
+          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(),
+            }
+          );
+          let maxFailures = 0;
+          // post vaa to Solana
+
+          const postPromise = postVaaWithRetry(
+            connection,
+            async (transaction) => {
+              await new Promise(function (resolve) {
+                //We delay here so the connection has time to get wrecked
+                setTimeout(function () {
+                  resolve(500);
+                });
+              });
+              transaction.partialSign(keypair);
+              return transaction;
+            },
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            payerAddress,
+            Buffer.from(signedVAA),
+            maxFailures
+          );
+
+          await postPromise;
+          // redeem tokens on solana
+          const transaction = await redeemOnSolana(
+            connection,
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            SOLANA_TOKEN_BRIDGE_ADDRESS,
+            payerAddress,
+            signedVAA
+          );
+          // sign, send, and confirm transaction
+          transaction.partialSign(keypair);
+          const txid = await connection.sendRawTransaction(
+            transaction.serialize()
+          );
+          await connection.confirmTransaction(txid);
+          expect(
+            await getIsTransferCompletedSolana(
+              SOLANA_TOKEN_BRIDGE_ADDRESS,
+              signedVAA,
+              connection
+            )
+          ).toBe(true);
+          done();
+        } catch (e) {
+          console.error(e);
+          done(
+            "An error occurred while happy-path testing post VAA with retry."
+          );
+        }
+      })();
+    });
+    test("Reject on signature failure", (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();
+          const sequence = await transferFromEthToSolana();
+          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(),
+            }
+          );
+          let maxFailures = 5;
+          // post vaa to Solana
+
+          let error = false;
+          try {
+            const postPromise = postVaaWithRetry(
+              connection,
+              async (transaction) => {
+                return Promise.reject();
+              },
+              SOLANA_CORE_BRIDGE_ADDRESS,
+              payerAddress,
+              Buffer.from(signedVAA),
+              maxFailures
+            );
+
+            await postPromise;
+          } catch (e) {
+            error = true;
+          }
+          expect(error).toBe(true);
+          done();
+        } catch (e) {
+          console.error(e);
+          done(
+            "An error occurred while trying to send from Ethereum to Solana"
+          );
+        }
+      })();
+    });
+  });
 });

+ 7 - 0
sdk/js/src/utils/array.ts

@@ -77,3 +77,10 @@ export const nativeToHexString = (
 
 export const uint8ArrayToNative = (a: Uint8Array, chainId: ChainId) =>
   hexToNativeString(uint8ArrayToHex(a), chainId);
+
+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));
+}

+ 58 - 0
sdk/js/src/utils/solana.ts

@@ -0,0 +1,58 @@
+import { Connection, PublicKey, Transaction } from "@solana/web3.js";
+
+/*
+    The transactions provided to this function should be ready to be sent.
+    This function will only add the feePayer and blockhash, and then sign, send, and confirm the transaction.
+*/
+export async function sendAndConfirmTransactionsWithRetry(
+  connection: Connection,
+  signTransaction: (transaction: Transaction) => Promise<Transaction>,
+  payer: string,
+  unsignedTransactions: Transaction[],
+  maxRetries: number = 0
+) {
+  if (!(unsignedTransactions && unsignedTransactions.length)) {
+    return Promise.reject("No transactions provided to send.");
+  }
+  let currentRetries = 0;
+  let currentIndex = 0;
+  const transactionReceipts = [];
+  while (
+    !(currentIndex >= unsignedTransactions.length) &&
+    !(currentRetries > maxRetries)
+  ) {
+    let transaction = unsignedTransactions[currentIndex];
+    let signed = null;
+    try {
+      const { blockhash } = await connection.getRecentBlockhash();
+      transaction.recentBlockhash = blockhash;
+      transaction.feePayer = new PublicKey(payer);
+    } catch (e) {
+      console.error(e);
+      currentRetries++;
+      //Behavior after this is undefined, so best just to restart and try again.
+      continue;
+    }
+    try {
+      signed = await signTransaction(transaction);
+    } catch (e) {
+      //Eject here because this is most likely an intentional rejection from the user, or a genuine unrecoverable failure.
+      return Promise.reject("Failed to sign transaction.");
+    }
+    try {
+      const txid = await connection.sendRawTransaction(signed.serialize());
+      const receipt = await connection.confirmTransaction(txid);
+      transactionReceipts.push(receipt);
+      currentIndex++;
+    } catch (e) {
+      console.error(e);
+      currentRetries++;
+    }
+  }
+
+  if (currentRetries > maxRetries) {
+    return Promise.reject("Reached the maximum number of retries.");
+  } else {
+    return Promise.resolve(transactionReceipts);
+  }
+}