Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions .github/workflows/contract-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ concurrency:

env:
CARGO_TERM_COLOR: always
VERBOSE: ${{ github.events.input.verbose }}
VERBOSE: ${{ github.event.inputs.verbose }}

permissions:
contents: read
Expand All @@ -38,24 +38,44 @@ jobs:

- name: Utilize Shared Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target/fast-runtime
contract-tests/bittensor -> target

- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/git
~/.cargo/registry
target/fast-runtime
contract-tests/bittensor/target
key: contract-tests-rust-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'contract-tests/bittensor/Cargo.lock', '**/Cargo.toml', 'node/**/*.rs', 'runtime/**/*.rs', 'pallets/**/*.rs', 'precompiles/**/*.rs', 'primitives/**/*.rs', 'common/**/*.rs', 'chain-extensions/**/*.rs', 'support/**/*.rs', 'contract-tests/bittensor/**/*.rs') }}
restore-keys: |
contract-tests-rust-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'contract-tests/bittensor/Cargo.lock', '**/Cargo.toml') }}-
contract-tests-rust-${{ runner.os }}-${{ runner.arch }}-

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Cache Yarn packages
uses: actions/cache@v4
with:
path: ~/.cache/yarn
key: contract-tests-yarn-${{ runner.os }}-${{ hashFiles('contract-tests/yarn.lock') }}
restore-keys: |
contract-tests-yarn-${{ runner.os }}-

- name: Install dependencies
run: |
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" build-essential clang curl libssl-dev llvm libudev-dev protobuf-compiler nodejs pkg-config
sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" build-essential clang curl libssl-dev llvm libudev-dev netcat-openbsd protobuf-compiler nodejs pkg-config

- name: Run tests
uses: nick-fields/retry@v3
with:
timeout_minutes: 120
max_attempts: 3
retry_wait_seconds: 60
command: |
cd ${{ github.workspace }}
npm install --global yarn
./contract-tests/run-ci.sh
timeout-minutes: 90
run: |
npm install --global yarn
./contract-tests/run-ci.sh
3 changes: 2 additions & 1 deletion contract-tests/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"scripts": {
"test": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --retries 3 --file src/setup.ts --require ts-node/register --extension ts \"test/**/*.ts\""
"test": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --retries 3 --file src/setup.ts --require ts-node/register --extension ts \"test/**/*.ts\"",
"test:ci:file": "TS_NODE_PREFER_TS_EXTS=1 TS_NODE_TRANSPILE_ONLY=1 mocha --timeout 999999 --file src/setup.ts --require ts-node/register --extension ts"
},
"keywords": [],
"author": "",
Expand Down
102 changes: 77 additions & 25 deletions contract-tests/run-ci.sh
Original file line number Diff line number Diff line change
@@ -1,61 +1,113 @@
#!/bin/bash
set -Eeuo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTRACT_TEST_DIR="$ROOT_DIR/contract-tests"
LOCALNET_LOG="${LOCALNET_LOG:-$ROOT_DIR/contract-tests-localnet.log}"
LOCALNET_START_TIMEOUT="${LOCALNET_START_TIMEOUT:-300}"
CONTRACT_TEST_FILE_ATTEMPTS="${CONTRACT_TEST_FILE_ATTEMPTS:-1}"

echo "start run-ci.sh"

cd contract-tests
cleanup() {
pkill node-subtensor >/dev/null 2>&1 || true
}

dump_localnet_log() {
if [ -f "$LOCALNET_LOG" ]; then
echo "---- last 200 localnet log lines ----"
tail -n 200 "$LOCALNET_LOG" || true
echo "-------------------------------------"
fi
}

trap cleanup EXIT

cd bittensor
cd "$CONTRACT_TEST_DIR/bittensor"

rustup component add rust-src
cargo install cargo-contract
cargo contract build --release
if ! command -v cargo-contract >/dev/null 2>&1; then
cargo install cargo-contract
else
echo "cargo-contract already installed"
fi
cargo contract build --release

cd ../..
cd "$ROOT_DIR"

scripts/localnet.sh &>/dev/null &
scripts/localnet.sh --build-only
BUILD_BINARY=0 scripts/localnet.sh >"$LOCALNET_LOG" 2>&1 &

i=1
while [ $i -le 2000 ]; do
for i in $(seq 1 "$LOCALNET_START_TIMEOUT"); do
if nc -z localhost 9944; then
echo "node subtensor is running after $i seconds"
break
fi
sleep 1
i=$((i + 1))
done

# port not available exit with error
if [ "$i" -eq 2000 ]; then
exit 1
if ! nc -z localhost 9944; then
echo "node subtensor did not start within ${LOCALNET_START_TIMEOUT}s"
dump_localnet_log
exit 1
fi

sleep 10

if ! nc -z localhost 9944; then
echo "node subtensor exit, port not available"
exit 1
echo "node subtensor exited, port not available"
dump_localnet_log
exit 1
fi

cd contract-tests
cd "$CONTRACT_TEST_DIR"

# required for papi in get-metadata.sh, but we cannot run yarn before papi as it adds the descriptors to the package.json which won't resolve
# Required for papi in get-metadata.sh; yarn install cannot run before papi
# because package.json references the generated descriptors package.
npm i -g polkadot-api

if ! command -v yarn >/dev/null 2>&1; then
npm install --global yarn
fi

bash get-metadata.sh

sleep 5

yarn install --frozen-lockfile

yarn run test
TEST_EXIT_CODE=$?

if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "Tests failed with exit code $TEST_EXIT_CODE"
pkill node-subtensor
exit $TEST_EXIT_CODE
if [ "$#" -gt 0 ]; then
test_files=("$@")
else
mapfile -t test_files < <(find test -maxdepth 1 -name "*.ts" -print | sort)
fi

pkill node-subtensor
failed_files=()
for test_file in "${test_files[@]}"; do
echo "Running $test_file"
passed=0

for attempt in $(seq 1 "$CONTRACT_TEST_FILE_ATTEMPTS"); do
if [ "$attempt" -gt 1 ]; then
echo "Retrying $test_file (attempt $attempt/$CONTRACT_TEST_FILE_ATTEMPTS)"
fi

if yarn run test:ci:file "$test_file"; then
passed=1
break
fi
done

if [ "$passed" -ne 1 ]; then
failed_files+=("$test_file")
fi
done

if [ "${#failed_files[@]}" -gt 0 ]; then
echo "Contract test files failed:"
printf ' - %s\n' "${failed_files[@]}"
dump_localnet_log
exit 1
fi

exit 0
exit 0
4 changes: 2 additions & 2 deletions contract-tests/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export const ETH_LOCAL_URL = 'http://localhost:9944'
export const SUB_LOCAL_URL = 'ws://localhost:9944'
export const SS58_PREFIX = 42;
// set the tx timeout as 2 second when eable the fast-runtime feature.
export const TX_TIMEOUT = 3000;
// Fast-runtime blocks are quick, but finality can lag during contract-heavy tests.
export const TX_TIMEOUT = 30000;

export const IED25519VERIFY_ADDRESS = "0x0000000000000000000000000000000000000402";
export const IEd25519VerifyABI = [
Expand Down
24 changes: 5 additions & 19 deletions contract-tests/src/substrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,23 +134,7 @@ export async function waitForTransactionWithRetry(
tx: Transaction<{}, string, string, void>,
signer: PolkadotSigner,
) {
let success = false;
let retries = 0;

// set max retries times
while (!success && retries < 5) {
await waitForTransactionCompletion(api, tx, signer)
.then(() => { success = true })
.catch((error) => {
console.log(`transaction error ${error}`);
});
await new Promise((resolve) => setTimeout(resolve, 1000));
retries += 1;
}

if (!success) {
console.log("Transaction failed after 5 retries");
}
await waitForTransactionCompletion(api, tx, signer)
}

export async function waitForTransactionCompletion(api: TypedApi<typeof devnet>, tx: Transaction<{}, string, string, void>, signer: PolkadotSigner,) {
Expand Down Expand Up @@ -185,6 +169,8 @@ export async function getTransactionWatchPromise(tx: Transaction<{}, string, str
clearTimeout(timeoutId);
if (!value.ok) {
console.log("Transaction threw an error:", value.dispatchError)
reject(new Error(`Transaction finalized with dispatch error: ${JSON.stringify(value.dispatchError)}`));
return;
}
// Resolve the promise when the transaction is finalized
resolve();
Expand All @@ -206,7 +192,7 @@ export async function getTransactionWatchPromise(tx: Transaction<{}, string, str
const timeoutId = setTimeout(() => {
subscription.unsubscribe();
console.log('unsubscribed because of timeout for tx {}', txHash);
reject()
reject(new Error(`Transaction was not finalized within ${TX_TIMEOUT}ms: ${txHash}`))
}, TX_TIMEOUT);
});
}
Expand Down Expand Up @@ -262,4 +248,4 @@ export function waitForFinalizedBlock(api: TypedApi<typeof devnet>, end: number)
}
})
})
}
}
7 changes: 5 additions & 2 deletions contract-tests/test/eth.chain-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ describe("Test the EVM chain ID", () => {
)

let tx = api.tx.AdminUtils.sudo_set_evm_chain_id({ chain_id: BigInt(100) })
await waitForTransactionWithRetry(api, tx, signer)
await assert.rejects(
waitForTransactionWithRetry(api, tx, signer),
/BadOrigin/,
)

// extrinsic should be failed and chain ID not updated.
chainId = await ethClient.getChainId();
assert.equal(chainId, 42);

});
});
});
37 changes: 25 additions & 12 deletions contract-tests/test/eth.substrate-transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ describe("Balance transfers between substrate and EVM", () => {
await disableWhiteListCheck(api, true)
});

async function getFundedWallet() {
const fundedWallet = generateRandomEthersWallet();
await forceSetBalanceToEthAddress(api, fundedWallet.address);
return fundedWallet;
}

it("Can transfer token from EVM to EVM", async () => {
const senderBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) })
const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet2.address) })
Expand Down Expand Up @@ -159,8 +165,10 @@ describe("Balance transfers between substrate and EVM", () => {

it("Forward value in smart contract", async () => {

const forwardWallet = await getFundedWallet();
const forwardSigner = new ethers.NonceManager(forwardWallet);

const contractFactory = new ethers.ContractFactory(WITHDRAW_CONTRACT_ABI, WITHDRAW_CONTRACT_BYTECODE, wallet)
const contractFactory = new ethers.ContractFactory(WITHDRAW_CONTRACT_ABI, WITHDRAW_CONTRACT_BYTECODE, forwardSigner)
const contract = await contractFactory.deploy()
await contract.waitForDeployment()

Expand All @@ -176,13 +184,13 @@ describe("Balance transfers between substrate and EVM", () => {
value: raoToEth(tao(2)).toString()
}

const txResponse = await wallet.sendTransaction(ethTransfer)
const txResponse = await forwardSigner.sendTransaction(ethTransfer)
await txResponse.wait();

const contractBalance = await publicClient.getBalance({ address: toViemAddress(contract.target.toString()) })
const callerBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) })
const callerBalance = await publicClient.getBalance({ address: toViemAddress(forwardWallet.address) })

const contractForCall = new ethers.Contract(contract.target.toString(), WITHDRAW_CONTRACT_ABI, wallet)
const contractForCall = new ethers.Contract(contract.target.toString(), WITHDRAW_CONTRACT_ABI, forwardSigner)

const withdrawTx = await contractForCall.withdraw(
raoToEth(tao(1)).toString()
Expand All @@ -191,36 +199,41 @@ describe("Balance transfers between substrate and EVM", () => {
await withdrawTx.wait();

const contractBalanceAfterWithdraw = await publicClient.getBalance({ address: toViemAddress(contract.target.toString()) })
const callerBalanceAfterWithdraw = await publicClient.getBalance({ address: toViemAddress(wallet.address) })
const callerBalanceAfterWithdraw = await publicClient.getBalance({ address: toViemAddress(forwardWallet.address) })

compareEthBalanceWithTxFee(callerBalanceAfterWithdraw, callerBalance + raoToEth(tao(1)))
assert.equal(contractBalance, contractBalanceAfterWithdraw + raoToEth(tao(1)))
});

it("Transfer full balance", async () => {
const ethBalance = await publicClient.getBalance({ address: toViemAddress(wallet.address) })
const receiverBalance = await publicClient.getBalance({ address: toViemAddress(wallet2.address) })
const fullBalanceWallet = await getFundedWallet();
const fullBalanceReceiver = generateRandomEthersWallet();
const ethBalance = await publicClient.getBalance({ address: toViemAddress(fullBalanceWallet.address) })
const receiverBalance = await publicClient.getBalance({ address: toViemAddress(fullBalanceReceiver.address) })
const tx = {
to: wallet2.address,
to: fullBalanceReceiver.address,
value: ethBalance.toString(),
};
const txPrice = await estimateTransactionCost(provider, tx);
const finalTx = {
to: wallet2.address,
to: fullBalanceReceiver.address,
value: (ethBalance - txPrice).toString(),
};
let failed = false;
try {
// transfer should be failed since substrate requires existial balance to keep account
const txResponse = await wallet.sendTransaction(finalTx)
const txResponse = await fullBalanceWallet.sendTransaction(finalTx)
await txResponse.wait();
} catch (error) {
if (error instanceof Error) {
failed = true;
assert.equal((error as any).code, "INSUFFICIENT_FUNDS")
assert.equal(error.toString().includes("insufficient funds"), true)
}
}
assert.equal(failed, true)

const receiverBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(wallet2.address) })
const receiverBalanceAfterTransfer = await publicClient.getBalance({ address: toViemAddress(fullBalanceReceiver.address) })
assert.equal(receiverBalance, receiverBalanceAfterTransfer)
})

Expand Down Expand Up @@ -404,4 +417,4 @@ async function transferAndGetFee(wallet: ethers.Wallet, wallet2: ethers.Wallet,
const fee = ethBalanceBefore - ethBalanceAfter - raoToEth(tao(1))

return fee;
}
}
Loading
Loading