diff --git a/contracts/BackedAutoFeeTokenImplementation.sol b/contracts/BackedAutoFeeTokenImplementation.sol index 7c07e86..b5e3d1d 100644 --- a/contracts/BackedAutoFeeTokenImplementation.sol +++ b/contracts/BackedAutoFeeTokenImplementation.sol @@ -91,6 +91,16 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA uint256 public newMultiplierActivationTime; MultiplierUpdate[] public multiplierUpdates; + // Multi-minter with per-address mint cap + mapping(address => uint256) public minterAllowance; + + // Multi-burner with per-address burn cap + mapping(address => uint256) public burnerAllowance; + + // Events + event MinterAllowanceChanged(address indexed minter, uint256 allowance); + event BurnerAllowanceChanged(address indexed burner, uint256 allowance); + function multiplierNonce() external view returns (uint256) { if(block.timestamp >= newMultiplierActivationTime) { return newMultiplierNonce; @@ -412,6 +422,80 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA periodLength = newPeriodLength; } + /** + * @dev Function to set the minting allowance for an address. Allowed only for owner. + * Set to 0 to revoke minting rights. + * + * Emits a { MinterAllowanceChanged } event + * + * @param minterAddress The address to set the allowance for + * @param allowance The maximum amount of tokens this address can mint + */ + function setMinterAllowance( + address minterAddress, + uint256 allowance + ) external onlyOwner { + minterAllowance[minterAddress] = allowance; + emit MinterAllowanceChanged(minterAddress, allowance); + } + + /** + * @dev Function to mint tokens. Allowed for the primary minter (unlimited) + * or any address with sufficient minterAllowance (capped). + * + * @param account The address that will receive the minted tokens + * @param amount The amount of tokens to mint + */ + function mint(address account, uint256 amount) override external { + address sender = _msgSender(); + if (sender == minter) { + // Primary minter: unlimited, backward-compatible + _mint(account, amount); + } else { + require(minterAllowance[sender] >= amount, "BackedToken: Minter allowance exceeded"); + minterAllowance[sender] -= amount; + _mint(account, amount); + } + } + + /** + * @dev Function to set the burning allowance for an address. Allowed only for owner. + * Set to 0 to revoke burning rights. + * + * Emits a { BurnerAllowanceChanged } event + * + * @param burnerAddress The address to set the allowance for + * @param allowance The maximum amount of tokens this address can burn + */ + function setBurnerAllowance( + address burnerAddress, + uint256 allowance + ) external onlyOwner { + burnerAllowance[burnerAddress] = allowance; + emit BurnerAllowanceChanged(burnerAddress, allowance); + } + + /** + * @dev Function to burn tokens. Allowed for the primary burner (unlimited) + * or any address with sufficient burnerAllowance (capped). + * The burned tokens must be from the caller (msg.sender), or from the contract itself. + * + * @param account The account from which the tokens will be burned + * @param amount The amount of tokens to be burned + */ + function burn(address account, uint256 amount) override external { + address sender = _msgSender(); + require(account == sender || account == address(this), "BackedToken: Cannot burn account"); + if (sender == burner) { + // Primary burner: unlimited, backward-compatible + _burn(account, amount); + } else { + require(burnerAllowance[sender] >= amount, "BackedToken: Burner allowance exceeded"); + burnerAllowance[sender] -= amount; + _burn(account, amount); + } + } + /** * @dev Function to change the contract multiplier, only if oldMultiplier did not change in the meantime. Allowed only for multiplierUpdater * diff --git a/contracts/WrappedBackedTokenImplementation.sol b/contracts/WrappedBackedTokenImplementation.sol index 5eb4813..6387ee9 100644 --- a/contracts/WrappedBackedTokenImplementation.sol +++ b/contracts/WrappedBackedTokenImplementation.sol @@ -43,6 +43,11 @@ import "@openzeppelin/contracts-upgradeable-new/utils/math/MathUpgradeable.sol"; import "./SanctionsList.sol"; import "./interfaces/IBackedAutoFeeToken.sol"; +interface ITokenMintBurnable { + function mint(address account, uint256 amount) external; + function burn(address account, uint256 amount) external; +} + /** * @dev * @@ -71,6 +76,18 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea // Roles: address public pauser; + // Bridge rate-limiting + struct BridgeConfig { + uint256 mintLimit; // max shares mintable per window + uint256 burnLimit; // max shares burnable per window + uint256 windowLength; // time window in seconds + uint256 currentMintWindowStart; // start of current mint window + uint256 mintedInWindow; // shares minted in current window + uint256 currentBurnWindowStart; // start of current burn window + uint256 burnedInWindow; // shares burned in current window + } + mapping(address => BridgeConfig) public bridges; + // EIP-712 Delegate Functionality: bool public delegateMode; mapping(address => bool) public delegateWhitelist; @@ -86,6 +103,7 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea // Events: event NewPauser(address indexed newPauser); + event BridgeConfigChanged(address indexed bridge, uint256 mintLimit, uint256 burnLimit, uint256 windowLength); event NewSanctionsList(address indexed newSanctionsList); event PauseModeChange(bool pauseMode); event NewTerms(string newTerms); @@ -162,6 +180,29 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea emit NewPauser(newPauser); } + /** + * @dev Function to configure a bridge address with rate-limited minting. + * Set mintLimit to 0 to revoke bridge rights. Allowed only for owner. + * + * Emits a { BridgeConfigChanged } event + * + * @param bridgeAddress The bridge address to configure + * @param mintLimit Max shares mintable per window (0 to revoke) + * @param windowLength Time window in seconds + */ + function setBridge(address bridgeAddress, uint256 mintLimit, uint256 burnLimit, uint256 windowLength) external onlyOwner { + bridges[bridgeAddress] = BridgeConfig({ + mintLimit: mintLimit, + burnLimit: burnLimit, + windowLength: windowLength, + currentMintWindowStart: block.timestamp, + mintedInWindow: 0, + currentBurnWindowStart: block.timestamp, + burnedInWindow: 0 + }); + emit BridgeConfigChanged(bridgeAddress, mintLimit, burnLimit, windowLength); + } + /** * @dev Function to change the contract Senctions List. Allowed only for owner * @@ -271,11 +312,56 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea */ function _deposit(address caller, address receiver, uint256 assetsRequested, uint256 shares) internal virtual override { IBackedAutoFeeToken assetToken = IBackedAutoFeeToken(asset()); - assetToken.transferSharesFrom(caller, address(this), shares); - _mint(receiver, shares); + BridgeConfig storage bridgeCfg = bridges[caller]; + + if (bridgeCfg.mintLimit > 0) { + // Bridge: mint underlying tokens to this contract instead of pulling from caller + + // Reset mint window if expired + if (block.timestamp >= bridgeCfg.currentMintWindowStart + bridgeCfg.windowLength) { + bridgeCfg.currentMintWindowStart = block.timestamp; + bridgeCfg.mintedInWindow = 0; + } + require(bridgeCfg.mintedInWindow + shares <= bridgeCfg.mintLimit, "WrappedBackedToken: Bridge mint limit exceeded"); + + uint256 sharesBefore = assetToken.sharesOf(address(this)); + uint256 underlyingAmount = convertToAssets(shares); + ITokenMintBurnable(asset()).mint(address(this), underlyingAmount); + uint256 actualShares = assetToken.sharesOf(address(this)) - sharesBefore; + + bridgeCfg.mintedInWindow += actualShares; + + _mint(receiver, actualShares); + uint256 assets = convertToAssets(actualShares); + emit Deposit(caller, receiver, assets, actualShares); + } else { + // Normal: pull underlying shares from caller + assetToken.transferSharesFrom(caller, address(this), shares); + _mint(receiver, shares); + uint256 assets = convertToAssets(shares); + emit Deposit(caller, receiver, assets, shares); + } + } - uint256 assets = convertToAssets(shares); - emit Deposit(caller, receiver, assets, shares); + /** + * @dev Burns wrapped tokens by redeeming the underlying shares. + * + * @param shares The amount of wrapped token shares to burn + */ + function burn(address owner, uint256 shares) external virtual returns (bool) { + redeem(shares, owner, owner); + return true; + } + + /** + * @dev Mints wrapped tokens by depositing the underlying shares. + * + * @param owner The address that will receive the minted wrapped tokens + * @param shares The amount of wrapped token shares to mint + */ + function mint(address owner, uint256 shares) external virtual returns (bool) { + mint(shares, owner); + return true; } /** @@ -295,7 +381,26 @@ contract WrappedBackedTokenImplementation is OwnableUpgradeable, ERC4626Upgradea _burn(owner, shares); IBackedAutoFeeToken assetToken = IBackedAutoFeeToken(asset()); - assetToken.transferShares(receiver, shares); + + BridgeConfig storage bridgeCfg = bridges[caller]; + if (bridgeCfg.burnLimit > 0) { + // Bridge: burn underlying tokens instead of transferring shares + + // Reset burn window if expired + if (block.timestamp >= bridgeCfg.currentBurnWindowStart + bridgeCfg.windowLength) { + bridgeCfg.currentBurnWindowStart = block.timestamp; + bridgeCfg.burnedInWindow = 0; + } + require(bridgeCfg.burnedInWindow + shares <= bridgeCfg.burnLimit, "WrappedBackedToken: Bridge burn limit exceeded"); + + bridgeCfg.burnedInWindow += shares; + + uint256 underlyingAmount = convertToAssets(shares); + ITokenMintBurnable(asset()).burn(address(this), underlyingAmount); + } else { + // Normal: transfer underlying shares to receiver + assetToken.transferShares(receiver, shares); + } uint256 assets = convertToAssets(shares); emit Withdraw(caller, receiver, owner, assets, shares); diff --git a/scripts/deploy-token-and-wrapper-with-bridge.ts b/scripts/deploy-token-and-wrapper-with-bridge.ts new file mode 100644 index 0000000..6c6b5ad --- /dev/null +++ b/scripts/deploy-token-and-wrapper-with-bridge.ts @@ -0,0 +1,149 @@ +import { ethers } from "hardhat"; + +async function main() { + const [deployer] = await ethers.getSigners(); + console.log("Deploying with account:", deployer.address); + + // --- Configuration --- + const bridgeAddress = deployer.address; // Replace with actual bridge address + const bridgeMintLimit = ethers.utils.parseEther("1000000"); // 1M shares per window + const bridgeBurnLimit = ethers.utils.parseEther("1000000"); // 1M shares per window + const bridgeWindowLength = 24 * 3600; // 24 hours + const minterAllowance = ethers.utils.parseEther("10000000"); // 10M tokens + + // --- Deploy SanctionsListMock --- + const SanctionsListMock = await ethers.getContractFactory( + "SanctionsListMock" + ); + const sanctionsList = await SanctionsListMock.deploy(); + await sanctionsList.deployed(); + console.log("SanctionsListMock deployed at:", sanctionsList.address); + + // --- Deploy BackedAutoFeeTokenFactory and token --- + const TokenFactory = await ethers.getContractFactory( + "BackedAutoFeeTokenFactory" + ); + const tokenFactory = await TokenFactory.deploy(deployer.address); + await tokenFactory.deployed(); + console.log("BackedAutoFeeTokenFactory deployed at:", tokenFactory.address); + + const tokenConfig = { + name: "Backed Test Token", + symbol: "bTEST", + tokenOwner: deployer.address, + minter: deployer.address, + burner: deployer.address, + pauser: deployer.address, + sanctionsList: sanctionsList.address, + multiplierUpdater: deployer.address, + periodLength: 24 * 3600, + lastTimeFeeApplied: Math.floor(Date.now() / 1000), + feePerPeriod: 0, + }; + + const deployTokenTx = await tokenFactory.deployToken(tokenConfig); + const deployTokenReceipt = await deployTokenTx.wait(); + const tokenEvent = deployTokenReceipt.events?.find( + (e: any) => e.event === "NewToken" + ); + const tokenAddress = tokenEvent?.args?.newToken; + console.log("BackedAutoFeeToken deployed at:", tokenAddress); + + // --- Deploy WrappedBackedTokenFactory and wrapper --- + const WrapperFactory = await ethers.getContractFactory( + "WrappedBackedTokenFactory" + ); + const wrapperFactory = await WrapperFactory.deploy(deployer.address); + await wrapperFactory.deployed(); + console.log("WrappedBackedTokenFactory deployed at:", wrapperFactory.address); + + const WrapperImpl = await ethers.getContractFactory( + "WrappedBackedTokenImplementation" + ); + const wrapperImpl = await WrapperImpl.deploy(); + await wrapperImpl.deployed(); + await wrapperFactory.updateImplementation(wrapperImpl.address); + console.log( + "WrappedBackedTokenImplementation deployed at:", + wrapperImpl.address + ); + + const wrapperConfig = { + name: "Wrapped Backed Test Token", + symbol: "wbTEST", + underlying: tokenAddress, + tokenOwner: deployer.address, + pauser: deployer.address, + sanctionsList: sanctionsList.address, + }; + + const deployWrapperTx = await wrapperFactory.deployToken(wrapperConfig); + const deployWrapperReceipt = await deployWrapperTx.wait(); + const wrapperEvent = deployWrapperReceipt.events?.find( + (e: any) => e.event === "NewToken" + ); + const wrapperAddress = wrapperEvent?.args?.newToken; + console.log("WrappedBackedToken deployed at:", wrapperAddress); + + // --- Set minter allowance on underlying token for the wrapper --- + const token = await ethers.getContractAt( + "BackedAutoFeeTokenImplementation", + tokenAddress + ); + const setMinterTx = await token.setMinterAllowance( + wrapperAddress, + minterAllowance + ); + await setMinterTx.wait(); + console.log( + "Minter allowance set for wrapper:", + ethers.utils.formatEther(minterAllowance) + ); + + // --- Configure bridge on the wrapper --- + const wrapper = await ethers.getContractAt( + "WrappedBackedTokenImplementation", + wrapperAddress + ); + const setBridgeTx = await wrapper.setBridge( + bridgeAddress, + bridgeMintLimit, + bridgeBurnLimit, + bridgeWindowLength + ); + await setBridgeTx.wait(); + console.log( + "Bridge configured:", + bridgeAddress, + "limit:", + ethers.utils.formatEther(bridgeMintLimit), + "window:", + bridgeWindowLength, + "s" + ); + + // --- Summary --- + console.log("\n=== Deployment Summary ==="); + console.log("SanctionsList: ", sanctionsList.address); + console.log("TokenFactory: ", tokenFactory.address); + console.log("BackedAutoFeeToken: ", tokenAddress); + console.log("WrapperFactory: ", wrapperFactory.address); + console.log("WrappedBackedToken: ", wrapperAddress); + console.log("Bridge: ", bridgeAddress); + console.log( + "Bridge mint limit: ", + ethers.utils.formatEther(bridgeMintLimit), + "per", + bridgeWindowLength / 3600, + "h" + ); + console.log( + "Wrapper minter allowance:", + ethers.utils.formatEther(minterAllowance) + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/BackedAutoFeeTokenImplementation.ts b/test/BackedAutoFeeTokenImplementation.ts index 1c7e79c..51b1adf 100644 --- a/test/BackedAutoFeeTokenImplementation.ts +++ b/test/BackedAutoFeeTokenImplementation.ts @@ -731,7 +731,7 @@ describe("BackedAutoFeeTokenImplementation", function () { it("Try to mint from unauthorized account", async function () { await token.setMinter(minter.address); await expect(token.mint(tmpAccount.address, 100)).to.revertedWith( - "BackedToken: Only minter" + "BackedToken: Minter allowance exceeded" ); }); @@ -802,7 +802,7 @@ describe("BackedAutoFeeTokenImplementation", function () { await token.setBurner(burner.address); await token.connect(minter.signer).mint(tmpAccount.address, 100); await expect(token.burn(tmpAccount.address, 100)).to.revertedWith( - "BackedToken: Only burner" + "BackedToken: Cannot burn account" ); }); diff --git a/test/BridgeMintE2E.ts b/test/BridgeMintE2E.ts new file mode 100644 index 0000000..734d313 --- /dev/null +++ b/test/BridgeMintE2E.ts @@ -0,0 +1,538 @@ +/* eslint-disable camelcase */ +/* eslint-disable prettier/prettier */ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { BigNumber, Signer } from "ethers"; +import * as helpers from "@nomicfoundation/hardhat-network-helpers"; +import { + BackedAutoFeeTokenImplementation, + BackedAutoFeeTokenFactory, + WrappedBackedTokenImplementation, + WrappedBackedTokenFactory, + SanctionsListMock, +} from "../typechain"; +import { cacheBeforeEach } from "./helpers"; + +type SignerWithAddress = { + signer: Signer; + address: string; +}; + +describe("Bridge Mint E2E", function () { + const tokenName = "Backed Test Token"; + const tokenSymbol = "bTEST"; + const wrappedTokenName = "Wrapped Backed Test Token"; + const wrappedTokenSymbol = "wbTEST"; + const baseTime = 2_000_000_000; + const periodLength = 24 * 3600; + const bridgeMintLimit = BigNumber.from(10).pow(18).mul(1_000_000); // 1M per window + const bridgeBurnLimit = BigNumber.from(10).pow(18).mul(1_000_000); // 1M per window + const bridgeWindowLength = 24 * 3600; // 24h + const minterAllowance = BigNumber.from(10).pow(18).mul(10_000_000); // 10M + + let token: BackedAutoFeeTokenImplementation; + let wrapped: WrappedBackedTokenImplementation; + let tokenFactory: BackedAutoFeeTokenFactory; + let wrapperFactory: WrappedBackedTokenFactory; + let sanctionsList: SanctionsListMock; + + let owner: SignerWithAddress; + let bridge: SignerWithAddress; + let bridge2: SignerWithAddress; + let user: SignerWithAddress; + let accounts: Signer[]; + + cacheBeforeEach(async () => { + accounts = await ethers.getSigners(); + + const getSigner = async (index: number): Promise => ({ + signer: accounts[index], + address: await accounts[index].getAddress(), + }); + + owner = await getSigner(0); + bridge = await getSigner(1); + bridge2 = await getSigner(2); + user = await getSigner(3); + + await helpers.time.setNextBlockTimestamp(baseTime); + + // Deploy sanctions list + sanctionsList = await ( + await ethers.getContractFactory("SanctionsListMock", owner.signer) + ).deploy(); + + // Deploy token via factory + const TokenFactory = await ethers.getContractFactory("BackedAutoFeeTokenFactory"); + tokenFactory = (await TokenFactory.deploy(owner.address)) as BackedAutoFeeTokenFactory; + + const tokenDeployReceipt = await ( + await tokenFactory.deployToken({ + name: tokenName, + symbol: tokenSymbol, + tokenOwner: owner.address, + minter: owner.address, + burner: owner.address, + pauser: owner.address, + sanctionsList: sanctionsList.address, + multiplierUpdater: owner.address, + periodLength: periodLength, + lastTimeFeeApplied: baseTime, + feePerPeriod: 0, + }) + ).wait(); + + const tokenAddress = tokenDeployReceipt.events?.find( + (e) => e.event === "NewToken" + )?.args?.newToken; + token = (await ethers.getContractAt( + "BackedAutoFeeTokenImplementation", + tokenAddress + )) as BackedAutoFeeTokenImplementation; + + // Deploy wrapper via factory + const WrapperFactory = await ethers.getContractFactory("WrappedBackedTokenFactory"); + wrapperFactory = (await WrapperFactory.deploy(owner.address)) as WrappedBackedTokenFactory; + + const WrapperImpl = await ethers.getContractFactory("WrappedBackedTokenImplementation"); + const wrapperImpl = await WrapperImpl.deploy(); + await wrapperFactory.updateImplementation(wrapperImpl.address); + + const wrapperDeployReceipt = await ( + await wrapperFactory.deployToken({ + name: wrappedTokenName, + symbol: wrappedTokenSymbol, + underlying: token.address, + tokenOwner: owner.address, + pauser: owner.address, + sanctionsList: sanctionsList.address, + }) + ).wait(); + + const wrapperAddress = wrapperDeployReceipt.events?.find( + (e) => e.event === "NewToken" + )?.args?.newToken; + wrapped = (await ethers.getContractAt( + "WrappedBackedTokenImplementation", + wrapperAddress + )) as WrappedBackedTokenImplementation; + + // Set minter and burner allowances on underlying token for the wrapper + await token.setMinterAllowance(wrapped.address, minterAllowance); + await token.setBurnerAllowance(wrapped.address, minterAllowance); + + // Configure bridge + await wrapped.setBridge(bridge.address, bridgeMintLimit, bridgeBurnLimit, bridgeWindowLength); + }); + + this.afterAll(async () => { + await helpers.reset(); + }); + + describe("Deployment verification", () => { + it("should deploy token with correct name and symbol", async () => { + expect(await token.name()).to.equal(tokenName); + expect(await token.symbol()).to.equal(tokenSymbol); + }); + + it("should deploy wrapper with correct name, symbol, and underlying", async () => { + expect(await wrapped.name()).to.equal(wrappedTokenName); + expect(await wrapped.symbol()).to.equal(wrappedTokenSymbol); + expect(await wrapped.asset()).to.equal(token.address); + }); + + it("should set minter allowance on underlying token for wrapper", async () => { + expect(await token.minterAllowance(wrapped.address)).to.equal(minterAllowance); + }); + + it("should configure bridge with correct limits", async () => { + const cfg = await wrapped.bridges(bridge.address); + expect(cfg.mintLimit).to.equal(bridgeMintLimit); + expect(cfg.burnLimit).to.equal(bridgeBurnLimit); + expect(cfg.windowLength).to.equal(bridgeWindowLength); + }); + }); + + describe("Bridge minting via deposit", () => { + it("should allow bridge to deposit without holding underlying tokens", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(1000); // 1000 tokens + + // Bridge has no underlying tokens + expect(await token.balanceOf(bridge.address)).to.equal(0); + + // Bridge deposits — _deposit mints underlying to wrapper + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + + // Bridge received wrapped tokens + const wrappedBalance = await wrapped.balanceOf(bridge.address); + expect(wrappedBalance).to.be.gt(0); + + // Wrapper holds underlying shares + const wrapperShares = await token.sharesOf(wrapped.address); + expect(wrapperShares).to.be.gt(0); + }); + + it("should allow bridge to mint (ERC4626 mint) without holding underlying tokens", async () => { + const mintShares = BigNumber.from(10).pow(18).mul(500); + + await wrapped.connect(bridge.signer).mint(mintShares, user.address); + + const userBalance = await wrapped.balanceOf(user.address); + // actualShares may differ by 1 due to rounding + expect(userBalance).to.be.gte(mintShares.sub(1)); + }); + + it("should decrease minter allowance on underlying token", async () => { + const allowanceBefore = await token.minterAllowance(wrapped.address); + + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + + const allowanceAfter = await token.minterAllowance(wrapped.address); + expect(allowanceAfter).to.be.lt(allowanceBefore); + }); + + it("should track mintedInWindow correctly", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + + const cfg = await wrapped.bridges(bridge.address); + expect(cfg.mintedInWindow).to.be.gt(0); + }); + + it("should emit Deposit event", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(100); + + await expect( + wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address) + ).to.emit(wrapped, "Deposit"); + }); + }); + + describe("Bridge rate limiting", () => { + it("should revert when bridge exceeds mint limit in a window", async () => { + const overLimit = bridgeMintLimit.add(BigNumber.from(10).pow(18)); + + await expect( + wrapped.connect(bridge.signer).deposit(overLimit, bridge.address) + ).to.be.revertedWith("WrappedBackedToken: Bridge mint limit exceeded"); + }); + + it("should allow minting up to the limit", async () => { + // Mint close to the limit (use slightly less to account for rounding) + const amount = bridgeMintLimit.sub(BigNumber.from(10).pow(18)); + await wrapped.connect(bridge.signer).deposit(amount, bridge.address); + + const balance = await wrapped.balanceOf(bridge.address); + expect(balance).to.be.gt(0); + }); + + it("should allow multiple mints within the limit", async () => { + const amount = BigNumber.from(10).pow(18).mul(100_000); // 100k each + + await wrapped.connect(bridge.signer).deposit(amount, bridge.address); + await wrapped.connect(bridge.signer).deposit(amount, bridge.address); + await wrapped.connect(bridge.signer).deposit(amount, bridge.address); + + const balance = await wrapped.balanceOf(bridge.address); + expect(balance).to.be.gt(0); + }); + + it("should reset window after windowLength passes", async () => { + // Mint close to limit + const amount = bridgeMintLimit.sub(BigNumber.from(10).pow(18)); + await wrapped.connect(bridge.signer).deposit(amount, bridge.address); + + // Trying to mint more should fail + await expect( + wrapped.connect(bridge.signer).deposit(amount, bridge.address) + ).to.be.revertedWith("WrappedBackedToken: Bridge mint limit exceeded"); + + // Advance time past the window + await helpers.time.increase(bridgeWindowLength + 1); + + // Now minting should work again + await wrapped.connect(bridge.signer).deposit(amount, bridge.address); + + const balance = await wrapped.balanceOf(bridge.address); + expect(balance).to.be.gt(0); + }); + + it("should track limits per bridge independently", async () => { + const bridge2Limit = BigNumber.from(10).pow(18).mul(500_000); // 500k + await wrapped.setBridge(bridge2.address, bridge2Limit, bridge2Limit, bridgeWindowLength); + + // Bridge 1 mints 800k + const amount1 = BigNumber.from(10).pow(18).mul(800_000); + await wrapped.connect(bridge.signer).deposit(amount1, bridge.address); + + // Bridge 2 can still mint up to its own limit + const amount2 = BigNumber.from(10).pow(18).mul(400_000); + await wrapped.connect(bridge2.signer).deposit(amount2, bridge2.address); + + // Bridge 2 exceeding its own limit should fail + await expect( + wrapped.connect(bridge2.signer).deposit(amount2, bridge2.address) + ).to.be.revertedWith("WrappedBackedToken: Bridge mint limit exceeded"); + }); + }); + + describe("Normal user deposit (non-bridge)", () => { + it("should still require underlying tokens for non-bridge users", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(100); + + // Mint some underlying tokens to the user + await token.mint(user.address, depositAmount); + await token.connect(user.signer).approve(wrapped.address, depositAmount); + + // User deposits normally + await wrapped.connect(user.signer).deposit(depositAmount, user.address); + + const balance = await wrapped.balanceOf(user.address); + expect(balance).to.be.gt(0); + }); + + it("should fail for non-bridge user without underlying tokens", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(100); + + // User has no tokens and no approval + await expect( + wrapped.connect(user.signer).deposit(depositAmount, user.address) + ).to.be.reverted; + }); + }); + + describe("Bridge configuration", () => { + it("should allow owner to update bridge config", async () => { + const newLimit = BigNumber.from(10).pow(18).mul(2_000_000); + const newWindow = 48 * 3600; + + await wrapped.setBridge(bridge.address, newLimit, newLimit, newWindow); + + const cfg = await wrapped.bridges(bridge.address); + expect(cfg.mintLimit).to.equal(newLimit); + expect(cfg.windowLength).to.equal(newWindow); + expect(cfg.mintedInWindow).to.equal(0); // reset on config change + }); + + it("should allow owner to revoke bridge by setting limit to 0", async () => { + await wrapped.setBridge(bridge.address, 0, 0, 0); + + // Bridge should now be treated as normal user and fail without tokens + const depositAmount = BigNumber.from(10).pow(18).mul(100); + await expect( + wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address) + ).to.be.reverted; + }); + + it("should emit BridgeConfigChanged event", async () => { + await expect( + wrapped.setBridge(bridge.address, bridgeMintLimit, bridgeBurnLimit, bridgeWindowLength) + ).to.emit(wrapped, "BridgeConfigChanged") + .withArgs(bridge.address, bridgeMintLimit, bridgeBurnLimit, bridgeWindowLength); + }); + + it("should not allow non-owner to configure bridge", async () => { + await expect( + wrapped.connect(bridge.signer).setBridge(bridge.address, bridgeMintLimit, bridgeBurnLimit, bridgeWindowLength) + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + + describe("Minter allowance on underlying token", () => { + it("should fail bridge mint when wrapper minter allowance is exhausted", async () => { + // Set a tiny minter allowance + await token.setMinterAllowance(wrapped.address, BigNumber.from(10).pow(18).mul(100)); + // Also set a large bridge limit so the bridge limit isn't the blocker + await wrapped.setBridge(bridge.address, BigNumber.from(10).pow(18).mul(1_000_000), bridgeBurnLimit, bridgeWindowLength); + + const depositAmount = BigNumber.from(10).pow(18).mul(200); // exceeds 100 minter allowance + + await expect( + wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address) + ).to.be.revertedWith("BackedToken: Minter allowance exceeded"); + }); + }); + + describe("Wrapped token redeemability (non-bridge)", () => { + it("should allow normal user to redeem for underlying tokens", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + + // Mint underlying to user, user deposits normally + await token.mint(user.address, depositAmount); + await token.connect(user.signer).approve(wrapped.address, depositAmount); + await wrapped.connect(user.signer).deposit(depositAmount, user.address); + + const wrappedBalance = await wrapped.balanceOf(user.address); + + // User redeems — should receive underlying tokens + await wrapped.connect(user.signer).redeem(wrappedBalance, user.address, user.address); + + const userTokenBalance = await token.balanceOf(user.address); + expect(userTokenBalance).to.be.gt(0); + expect(userTokenBalance.sub(depositAmount).abs()).to.be.lte(2); + }); + }); + + describe("Bridge burning via withdraw/redeem", () => { + it("should burn underlying tokens when bridge redeems", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + + // Bridge mints wrapped tokens + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + const wrappedBalance = await wrapped.balanceOf(bridge.address); + + const totalSupplyBefore = await token.totalSupply(); + + // Bridge redeems — underlying tokens should be burned, not transferred + await wrapped.connect(bridge.signer).redeem(wrappedBalance, bridge.address, bridge.address); + + const totalSupplyAfter = await token.totalSupply(); + + // Underlying total supply should have decreased + expect(totalSupplyAfter).to.be.lt(totalSupplyBefore); + + // Bridge should NOT have received underlying tokens + expect(await token.balanceOf(bridge.address)).to.equal(0); + + // Wrapped token balance should be 0 + expect(await wrapped.balanceOf(bridge.address)).to.equal(0); + }); + + it("should burn underlying tokens when bridge withdraws", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + + const totalSupplyBefore = await token.totalSupply(); + const withdrawAmount = depositAmount.div(2); + + await wrapped.connect(bridge.signer).withdraw(withdrawAmount, bridge.address, bridge.address); + + const totalSupplyAfter = await token.totalSupply(); + expect(totalSupplyAfter).to.be.lt(totalSupplyBefore); + expect(await token.balanceOf(bridge.address)).to.equal(0); + }); + + it("should emit Withdraw event on bridge redeem", async () => { + const depositAmount = BigNumber.from(10).pow(18).mul(100); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + const wrappedBalance = await wrapped.balanceOf(bridge.address); + + await expect( + wrapped.connect(bridge.signer).redeem(wrappedBalance, bridge.address, bridge.address) + ).to.emit(wrapped, "Withdraw"); + }); + + it("should fail bridge burn when wrapper burner allowance is exhausted", async () => { + // Set a tiny burner allowance + await token.setBurnerAllowance(wrapped.address, BigNumber.from(10).pow(18).mul(50)); + + const depositAmount = BigNumber.from(10).pow(18).mul(100); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + const wrappedBalance = await wrapped.balanceOf(bridge.address); + + // Redeeming all should exceed the 50-token burner allowance + await expect( + wrapped.connect(bridge.signer).redeem(wrappedBalance, bridge.address, bridge.address) + ).to.be.revertedWith("BackedToken: Burner allowance exceeded"); + }); + + it("should revert when bridge exceeds burn limit in a window", async () => { + // Set a small burn limit + const smallBurnLimit = BigNumber.from(10).pow(18).mul(500); + await wrapped.setBridge(bridge.address, bridgeMintLimit, smallBurnLimit, bridgeWindowLength); + + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + const wrappedBalance = await wrapped.balanceOf(bridge.address); + + // Redeeming all exceeds 500 burn limit + await expect( + wrapped.connect(bridge.signer).redeem(wrappedBalance, bridge.address, bridge.address) + ).to.be.revertedWith("WrappedBackedToken: Bridge burn limit exceeded"); + }); + + it("should reset burn window after windowLength passes", async () => { + // Set a small burn limit + const smallBurnLimit = BigNumber.from(10).pow(18).mul(500); + await wrapped.setBridge(bridge.address, bridgeMintLimit, smallBurnLimit, bridgeWindowLength); + + // Deposit 1000 + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + + // Burn 400 (within limit) + const burnAmount = BigNumber.from(10).pow(18).mul(400); + await wrapped.connect(bridge.signer).redeem(burnAmount, bridge.address, bridge.address); + + // Burn another 400 should fail (800 > 500 limit) + await expect( + wrapped.connect(bridge.signer).redeem(burnAmount, bridge.address, bridge.address) + ).to.be.revertedWith("WrappedBackedToken: Bridge burn limit exceeded"); + + // Advance time past window + await helpers.time.increase(bridgeWindowLength + 1); + + // Now burn should work again + await wrapped.connect(bridge.signer).redeem(burnAmount, bridge.address, bridge.address); + }); + + it("should decrease burner allowance on underlying token", async () => { + const allowanceBefore = await token.burnerAllowance(wrapped.address); + + const depositAmount = BigNumber.from(10).pow(18).mul(1000); + await wrapped.connect(bridge.signer).deposit(depositAmount, bridge.address); + const wrappedBalance = await wrapped.balanceOf(bridge.address); + + await wrapped.connect(bridge.signer).redeem(wrappedBalance, bridge.address, bridge.address); + + const allowanceAfter = await token.burnerAllowance(wrapped.address); + expect(allowanceAfter).to.be.lt(allowanceBefore); + }); + }); + + describe("Burner allowance on underlying token", () => { + it("should allow owner to set burner allowance", async () => { + const newAllowance = BigNumber.from(10).pow(18).mul(5000); + await token.setBurnerAllowance(user.address, newAllowance); + expect(await token.burnerAllowance(user.address)).to.equal(newAllowance); + }); + + it("should emit BurnerAllowanceChanged event", async () => { + const newAllowance = BigNumber.from(10).pow(18).mul(5000); + await expect(token.setBurnerAllowance(user.address, newAllowance)) + .to.emit(token, "BurnerAllowanceChanged") + .withArgs(user.address, newAllowance); + }); + + it("should allow capped burner to burn own tokens", async () => { + const amount = BigNumber.from(10).pow(18).mul(100); + await token.mint(user.address, amount); + await token.setBurnerAllowance(user.address, amount); + + await token.connect(user.signer).burn(user.address, amount); + expect(await token.balanceOf(user.address)).to.equal(0); + }); + + it("should not allow capped burner to burn other accounts' tokens", async () => { + const amount = BigNumber.from(10).pow(18).mul(100); + await token.mint(owner.address, amount); + await token.setBurnerAllowance(user.address, amount); + + await expect( + token.connect(user.signer).burn(owner.address, amount) + ).to.be.revertedWith("BackedToken: Cannot burn account"); + }); + + it("should revert when capped burner exceeds allowance", async () => { + const amount = BigNumber.from(10).pow(18).mul(100); + await token.mint(user.address, amount); + await token.setBurnerAllowance(user.address, amount.div(2)); + + await expect( + token.connect(user.signer).burn(user.address, amount) + ).to.be.revertedWith("BackedToken: Burner allowance exceeded"); + }); + }); +});