Add framework for Terra contract testing (#1050)

* Add terra integration test framework

* Update README

* Remove comment

* Update README

* Add .gitignore
This commit is contained in:
Karl 2022-04-06 11:25:18 -05:00 committed by GitHub
parent e1f4b8e10b
commit 6a00c3b44c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 12681 additions and 0 deletions

View File

@ -54,6 +54,15 @@ jobs:
node-version: '16'
- run: cd ethereum && make test
terra:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: cd terra && make test
# Run linters, Go tests and other outside-of-Tilt things.
lint-and-tests:
# The linter is slow enough that we want to run it on the self-hosted runner

3
terra/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
LocalTerra
artifacts
!artifacts/cw20_base.wasm

View File

@ -53,7 +53,24 @@ deploy/nft_bridge: nft_bridge-code-id-$(NETWORK).txt
tools/node_modules: tools/package-lock.json
cd tools && npm ci
LocalTerra:
git clone --depth 1 https://www.github.com/terra-money/LocalTerra
test/node_modules: test/package-lock.json
cd test && npm ci
.PHONY: test
## Run integration test
test: artifacts test/node_modules LocalTerra
@if pgrep terrad; then echo "Error: terrad already running. Stop it before running tests"; exit 1; fi
cd LocalTerra && docker compose up --detach
sleep 5
cd test && npm run test || (cd ../LocalTerra && docker compose down && exit 1)
cd LocalTerra && docker compose down
.PHONY: clean
clean:
rm -f $(WASMS)
rm -f artifacts/checksums.txt
rm -rf tools/node_modules
rm -rf test/node_modules

View File

@ -39,6 +39,20 @@ wormhole/terra $ cat artifacts/checksums.txt
Once you have verified the Terra contracts are deterministic with a peer, you can now move to the deploy step.
## Run tests
**Disclaimer: Currently the only test that exists is for the token bridge's transfer.**
You can run the integration test suite on the artifacts you built.
```console
wormhole/terra $ make test
```
This command deploys your artifacts and performs various interactions with your
contracts in a LocalTerra node. Any new functionality (including expected errors)
to the contracts should be added to this test suite.
## Deploy Contracts
Now that you have built and verified checksums, you can now deploy one or more relevant contracts to the Terra blockchain.

33
terra/test/README.md Normal file
View File

@ -0,0 +1,33 @@
# Wormhole Contract Test Suite
## Running Local Terra Node
In order to run these tests, you need to have a local Terra node running. These tests are meant to be run using [LocalTerra](https://github.com/terra-money/LocalTerra). This requires [Docker Compose](https://docs.docker.com/compose/install/) to run. You can also run _terrad_ with the same set up Tilt uses (see configuration [here](../../devnet/terra-devnet.yaml)).
## Build
In the [terra root directory](../), run the following:
```sh
make artifacts
```
## Run the Test Suite
The easy way would be to navigate to the [terra root directory](../), run the following:
```sh
make test
```
If you plan on adding new tests and plan on persisting LocalTerra, make sure dependencies are installed:
```sh
npm ci
```
And run in this directory:
```sh
npm run test
```
These tests are built using Jest and is meant to be structured very similarly to the [ethereum unit tests](../../ethereum), which requires running a local node via ganache before _truffle_ can run any of the testing scripts in the [test directory](../../ethereum/test).
**Currently the only test that exists is for the token bridge's transfer.**

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"],
"testTimeout": 60000
}

12023
terra/test/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
terra/test/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "test",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"test": "jest --config jestconfig.json --verbose"
},
"author": "",
"license": "ISC",
"dependencies": {
"@cosmjs/encoding": "^0.26.2",
"@terra-money/terra.js": "^3.0.9",
"elliptic": "^6.5.4",
"ts-jest": "^27.1.4",
"web3-eth-abi": "^1.7.1",
"web3-utils": "^1.7.1",
"yargs": "^17.0.1"
},
"devDependencies": {
"@types/node": "^17.0.23",
"prettier": "^2.6.1",
"tslint": "^6.1.3",
"typescript": "^4.6.3"
}
}

View File

@ -0,0 +1,330 @@
import { describe, expect, jest, test } from "@jest/globals";
import { Bech32, toHex } from "@cosmjs/encoding";
import { Int, MsgExecuteContract } from "@terra-money/terra.js";
import { makeProviderAndWallet, transactWithoutMemo } from "../helpers/client";
import {
makeGovernanceVaaPayload,
makeTransferVaaPayload,
signAndEncodeVaa,
TEST_SIGNER_PKS,
} from "../helpers/vaa";
import { storeCode, deploy } from "../instantiate";
jest.setTimeout(60000);
const GOVERNANCE_CHAIN = 1;
const GOVERNANCE_ADDRESS =
"0000000000000000000000000000000000000000000000000000000000000004";
const FOREIGN_CHAIN = 1;
const FOREIGN_TOKEN_BRIDGE =
"000000000000000000000000000000000000000000000000000000000000ffff";
const GUARDIAN_SET_INDEX = 0;
const CONSISTENCY_LEVEL = 0;
const WASM_WORMHOLE = "../artifacts/wormhole.wasm";
const WASM_WRAPPED_ASSET = "../artifacts/cw20_wrapped.wasm";
const WASM_TOKEN_BRIDGE = "../artifacts/token_bridge.wasm";
// global map of contract addresses for all tests
const contracts = new Map<string, string>();
/*
Mirror ethereum/test/bridge.js
> should be initialized with the correct signers and values
> should register a foreign bridge implementation correctly
> should accept a valid upgrade
> bridged tokens should only be mint- and burn-able by owner (??)
> should attest a token correctly
> should correctly deploy a wrapped asset for a token attestation
> should correctly update a wrapped asset for a token attestation
> should deposit and log transfers correctly
> should deposit and log fee token transfers correctly
> should transfer out locked assets for a valid transfer vm
> should mint bridged assets wrappers on transfer from another chain and handle fees correctly
> should burn bridged assets wrappers on transfer to another chain
> should handle ETH deposits correctly (uusd)
> should handle ETH withdrawals and fees correctly (uusd)
> should revert on transfer out of a total of > max(uint64) tokens
*/
describe("Bridge Tests", () => {
test("Deploy Contracts", (done) => {
(async () => {
try {
const [client, wallet] = await makeProviderAndWallet();
const governanceAddress = Buffer.from(
GOVERNANCE_ADDRESS,
"hex"
).toString("base64");
// wormhole
const wormhole = await deploy(client, wallet, WASM_WORMHOLE, {
gov_chain: GOVERNANCE_CHAIN,
gov_address: governanceAddress,
guardian_set_expirity: 86400,
initial_guardian_set: {
addresses: [
{
bytes: Buffer.from(
"beFA429d57cD18b7F8A4d91A2da9AB4AF05d0FBe",
"hex"
).toString("base64"),
},
],
expiration_time: 0,
},
});
// token bridge
const wrappedAssetCodeId = await storeCode(
client,
wallet,
WASM_WRAPPED_ASSET
);
const tokenBridge = await deploy(client, wallet, WASM_TOKEN_BRIDGE, {
gov_chain: GOVERNANCE_CHAIN,
gov_address: governanceAddress,
wormhole_contract: wormhole,
wrapped_asset_code_id: wrappedAssetCodeId,
});
contracts.set("wormhole", wormhole);
contracts.set("tokenBridge", tokenBridge);
done();
} catch (e) {
console.error(e);
done("Failed to Deploy Contracts");
}
})();
});
test("Register a Foreign Bridge Implementation", (done) => {
(async () => {
try {
const [client, wallet] = await makeProviderAndWallet();
const vaaPayload = makeGovernanceVaaPayload(
GOVERNANCE_CHAIN,
FOREIGN_CHAIN,
FOREIGN_TOKEN_BRIDGE
);
console.info("vaaPayload", vaaPayload);
const timestamp = 1;
const nonce = 1;
const sequence = 0;
const signedVaa = signAndEncodeVaa(
timestamp,
nonce,
GOVERNANCE_CHAIN,
GOVERNANCE_ADDRESS,
sequence,
vaaPayload,
TEST_SIGNER_PKS,
GUARDIAN_SET_INDEX,
CONSISTENCY_LEVEL
);
console.info("signedVaa", signedVaa);
const tokenBridge = contracts.get("tokenBridge")!;
const submitVaa = new MsgExecuteContract(
wallet.key.accAddress,
tokenBridge,
{
submit_vaa: {
data: Buffer.from(signedVaa, "hex").toString("base64"),
},
}
);
const receipt = await transactWithoutMemo(client, wallet, [submitVaa]);
console.info("receipt", receipt.txhash);
done();
} catch (e) {
console.error(e);
done("Failed to Register a Foreign Bridge Implementation");
}
})();
});
test("Initiate Transfer (native denom)", (done) => {
(async () => {
try {
const [client, wallet] = await makeProviderAndWallet();
const tokenBridge = contracts.get("tokenBridge")!;
// transfer uusd
const denom = "uusd";
const recipientAddress =
"0000000000000000000000004206942069420694206942069420694206942069";
const amount = "100000000"; // one benjamin
const relayerFee = "1000000"; // one dolla
const walletAddress = wallet.key.accAddress;
// need to deposit before initiating transfer
const deposit = new MsgExecuteContract(
wallet.key.accAddress,
tokenBridge,
{
deposit_tokens: {},
},
{ [denom]: amount }
);
const initiateTransfer = new MsgExecuteContract(
walletAddress,
tokenBridge as string,
{
initiate_transfer: {
asset: {
amount,
info: {
native_token: {
denom,
},
},
},
recipient_chain: 2,
recipient: Buffer.from(recipientAddress, "hex").toString(
"base64"
),
fee: relayerFee,
nonce: 69,
},
}
);
// check balances
let balanceBefore = new Int(0);
{
const [balance] = await client.bank.balance(tokenBridge);
const coin = balance.get(denom);
if (coin !== undefined) {
balanceBefore = new Int(coin.amount);
}
}
// execute outbound transfer
const receipt = await transactWithoutMemo(client, wallet, [
deposit,
initiateTransfer,
]);
console.info("receipt", receipt.txhash);
let balanceAfter: Int;
{
const [balance] = await client.bank.balance(tokenBridge);
const coin = balance.get(denom);
expect(!coin).toBeFalsy();
balanceAfter = new Int(coin!.amount);
}
expect(
balanceBefore.add(new Int(amount)).eq(balanceAfter)
).toBeTruthy();
done();
} catch (e) {
console.error(e);
done("Failed to Initiate Transfer (native denom)");
}
})();
});
test("Complete Transfer (native denom)", (done) => {
(async () => {
try {
const [client, wallet] = await makeProviderAndWallet();
const tokenBridge = contracts.get("tokenBridge")!;
const denom = "uusd";
const amount = "100000000"; // one benjamin
const relayerFee = "1000000"; // one dolla
const walletAddress = wallet.key.accAddress;
const recipient = "terra17lmam6zguazs5q5u6z5mmx76uj63gldnse2pdp";
// check balances
let balanceBefore = new Int(0);
{
const [balance] = await client.bank.balance(recipient);
const coin = balance.get(denom);
if (coin !== undefined) {
balanceBefore = new Int(coin.amount);
}
}
const encodedTo = nativeToHex(recipient);
console.log("encodedTo", encodedTo);
const ustAddress =
"0100000000000000000000000000000000000000000000000000000075757364";
const vaaPayload = makeTransferVaaPayload(
1,
amount,
ustAddress,
encodedTo,
3,
relayerFee,
undefined
);
console.info("vaaPayload", vaaPayload);
const timestamp = 0;
const nonce = 0;
const sequence = 0;
const signedVaa = signAndEncodeVaa(
timestamp,
nonce,
FOREIGN_CHAIN,
FOREIGN_TOKEN_BRIDGE,
sequence,
vaaPayload,
TEST_SIGNER_PKS,
GUARDIAN_SET_INDEX,
CONSISTENCY_LEVEL
);
console.info("signedVaa", signedVaa);
const submitVaa = new MsgExecuteContract(walletAddress, tokenBridge, {
submit_vaa: {
data: Buffer.from(signedVaa, "hex").toString("base64"),
},
});
// execute inbound transfer with signed vaa
const receipt = await transactWithoutMemo(client, wallet, [submitVaa]);
console.info("receipt", receipt.txhash);
let balanceAfter: Int;
{
const [balance] = await client.bank.balance(recipient);
const coin = balance.get(denom);
expect(!coin).toBeFalsy();
balanceAfter = new Int(coin!.amount);
}
const expectedAmount = (new Int(amount)).sub(relayerFee);
expect(
//balanceBefore.add(new Int(expectedAmount)).eq(balanceAfter)
balanceBefore.add(expectedAmount).eq(balanceAfter)
).toBeTruthy();
done();
} catch (e) {
console.error(e);
done("Failed to Complete Transfer (native denom)");
}
})();
});
});
function nativeToHex(address: string) {
return toHex(Bech32.decode(address).data).padStart(64, "0");
}

View File

@ -0,0 +1,50 @@
import {
BlockTxBroadcastResult,
LCDClient,
MnemonicKey,
Msg,
Wallet,
} from "@terra-money/terra.js";
export async function makeProviderAndWallet(): Promise<[LCDClient, Wallet]> {
// provider
const client = new LCDClient({
URL: "http://localhost:1317",
chainID: "localterra",
});
// wallet
const mnemonic =
"notice oak worry limit wrap speak medal online prefer cluster roof addict wrist behave treat actual wasp year salad speed social layer crew genius";
const wallet = client.wallet(
new MnemonicKey({
mnemonic,
})
);
await wallet.sequence();
return [client, wallet];
}
export async function transact(
client: LCDClient,
wallet: Wallet,
msgs: Msg[],
memo: string
): Promise<BlockTxBroadcastResult> {
const tx = await wallet.createAndSignTx({
msgs: msgs,
memo: memo,
});
return client.tx.broadcast(tx);
}
export async function transactWithoutMemo(
client: LCDClient,
wallet: Wallet,
msgs: Msg[]
): Promise<BlockTxBroadcastResult> {
return transact(client, wallet, msgs, "");
}

View File

@ -0,0 +1,115 @@
import { soliditySha3 } from "web3-utils";
const abi = require("web3-eth-abi");
const elliptic = require("elliptic");
export const TEST_SIGNER_PKS = [
"cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0",
];
export function makeGovernanceVaaPayload(
governanceChain: number,
foreignChain: number,
foreignTokenBridge: string
) {
return (
"000000000000000000000000000000000000000000546f6b656e427269646765" +
abi.encodeParameter("uint16", governanceChain).substring(2 + 62) +
"0000" +
abi.encodeParameter("uint16", foreignChain).substring(2 + (64 - 4)) +
foreignTokenBridge
);
}
export function makeTransferVaaPayload(
payloadType: number,
amount: string,
hexlifiedTokenAddress: string,
encodedTo: string,
toChain: number,
relayerFee: string,
additionalPayload: string | undefined
): string {
const data =
abi.encodeParameter("uint8", payloadType).substring(2 + 62) +
// amount
abi.encodeParameter("uint256", amount).substring(2) +
// tokenaddress
hexlifiedTokenAddress +
// tokenchain
"0003" + // we only care about terra-specific tokens for these tests
// receiver
encodedTo +
// receiving chain
abi.encodeParameter("uint16", toChain).substring(2 + (64 - 4)) +
// fee
abi.encodeParameter("uint256", relayerFee).substring(2);
// additional payload
if (additionalPayload === undefined) {
additionalPayload = "";
}
return data + Buffer.from(additionalPayload, "utf8").toString("hex");
}
export function signAndEncodeVaa(
timestamp: number,
nonce: number,
emitterChainId: number,
emitterAddress: string,
sequence: number,
data: string,
signers: string[],
guardianSetIndex: number,
consistencyLevel: number
): string {
const body: string[] = [
abi.encodeParameter("uint32", timestamp).substring(2 + (64 - 8)),
abi.encodeParameter("uint32", nonce).substring(2 + (64 - 8)),
abi.encodeParameter("uint16", emitterChainId).substring(2 + (64 - 4)),
emitterAddress,
abi.encodeParameter("uint64", sequence).substring(2 + (64 - 16)),
abi.encodeParameter("uint8", consistencyLevel).substring(2 + (64 - 2)),
data,
];
const hash = soliditySha3(soliditySha3("0x" + body.join(""))!)!;
let signatures = "";
for (const i in signers) {
const ec = new elliptic.ec("secp256k1");
const key = ec.keyFromPrivate(signers[i]);
const signature = key.sign(hash.substring(2), { canonical: true });
const packSig = [
abi.encodeParameter("uint8", i).substring(2 + (64 - 2)),
zeroPadBytes(signature.r.toString(16), 32),
zeroPadBytes(signature.s.toString(16), 32),
abi
.encodeParameter("uint8", signature.recoveryParam)
.substr(2 + (64 - 2)),
];
signatures += packSig.join("");
}
const vm = [
abi.encodeParameter("uint8", 1).substring(2 + (64 - 2)),
abi.encodeParameter("uint32", guardianSetIndex).substring(2 + (64 - 8)),
abi.encodeParameter("uint8", signers.length).substring(2 + (64 - 2)),
signatures,
body.join(""),
].join("");
return vm;
}
function zeroPadBytes(value: string, length: number) {
while (value.length < 2 * length) {
value = "0" + value;
}
return value;
}

View File

@ -0,0 +1,48 @@
import {
LCDClient,
MsgInstantiateContract,
MsgStoreCode,
Wallet,
} from "@terra-money/terra.js";
import { readFileSync } from "fs";
import { transactWithoutMemo } from "./helpers/client";
export async function storeCode(
terra: LCDClient,
wallet: Wallet,
wasm: string
): Promise<number> {
const contract_bytes = readFileSync(wasm);
const store_code = new MsgStoreCode(
wallet.key.accAddress,
contract_bytes.toString("base64")
);
const receipt = await transactWithoutMemo(terra, wallet, [store_code]);
// @ts-ignore
const ci = /"code_id","value":"([^"]+)/gm.exec(receipt.raw_log)[1];
return parseInt(ci);
}
export async function deploy(
terra: LCDClient,
wallet: Wallet,
wasm: string,
instantiateMsg: Object
): Promise<string> {
const codeId = await storeCode(terra, wallet, wasm);
const msgs = [
new MsgInstantiateContract(
wallet.key.accAddress,
wallet.key.accAddress,
codeId,
instantiateMsg
),
];
const receipt = await transactWithoutMemo(terra, wallet, msgs);
// @ts-ignore
return /"contract_address","value":"([^"]+)/gm.exec(receipt.raw_log)[1];
}

5
terra/test/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"compilerOptions": {
"esModuleInterop": true
}
}