Files
liquid/script/DeployLocal.s.sol
zeekayandHanzo Dev 3e0779e9c0 the bit-scan counted every branch it did not take
Log2NegFrac shifts conditionally and counts unconditionally — the braces
were never there — so msb is 126 or 127 whatever x is, the integer part of
the logarithm is always 1 or 2, and the fraction is read off a misaligned
word. A redemption records a weight increment and every account later
scales its share by it, and that round trip is exact to 75% and then
collapses: a redemption taking 99% of the locked collateral charged the
accounts 50%. The other 49% stayed in the protocol, charged to nobody and
owed to everybody. That is the collateral leak, and no amount of rounding
work would have found it.

The pot was wrong twice more. It filled at the collateralization bar and
drained at par, so every redemption stranded the bar's whole margin in the
denominator — ninety-nine thousand units still locked against no debt. And
_sync restated each account's share at the live price while no price ever
reached the total, so after a doubling the protocol gave up twice what it
charged. _relock is now the only writer and carries the difference to the
total, so the identity holds by construction rather than by arithmetic
that happens to agree.

killSwitch was declared, assigned, and read nowhere. It sat in one of the
seven tests that a block comment opened at line 168 and closed at 277.

The suite could not have caught any of it. The commit that says it
registers eight submodules is empty — its tree hash equals its parent's —
so a fresh clone gets three of eleven libraries and compiles nothing. It
stayed green because the runners reuse a workspace where the untracked
lib/ survives between runs. Gitlinks are staged now and foundry.lock
matches what actually builds.

Five invariants fail at HEAD once the dead transmuter handler is filled
in, and all five are the protocol rather than the harness. Two are fixed
here. Five still fail: a repayment and the claim after it are supposed to
cancel and burn 426 wei more synthetic than they retire debt, which leaves
one account owing a wei more than the whole book and its position
permanently unrepayable. That one is isolated, not fixed.

A pot invariant I had added is removed rather than shipped with a bound I
could not derive. The identity holds to a wei deterministically; the
campaign still drifts through liquidation and strategy loss, and asserting
a tolerance nobody can defend is how this suite got here.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-27 00:12:07 -07:00

189 lines
8.3 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Script, console} from "forge-std/Script.sol";
import {Liquid} from "../src/Liquid.sol";
import {LiquidPosition} from "../src/LiquidPosition.sol";
import {LiquidTransmuter} from "../src/LiquidTransmuter.sol";
import {LiquidETHVault} from "../src/LiquidETHVault.sol";
import {LiquidTokenVault} from "../src/LiquidTokenVault.sol";
import {LiquidCurator} from "../src/LiquidCurator.sol";
import {LiquidStrategyClassifier} from "../src/LiquidStrategyClassifier.sol";
import {LiquidCompliance} from "../src/LiquidCompliance.sol";
import {SecurityTokenAdapter} from "../src/adapters/SecurityTokenAdapter.sol";
import {ILiquid, LiquidInitializationParams} from "../src/interfaces/ILiquid.sol";
import {ILiquidTransmuter} from "../src/interfaces/ILiquidTransmuter.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
/// @dev Minimal ERC-20 for local dev tokens (WLUX, LLUX, mock securities)
contract DevToken {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(string memory _name, string memory _symbol, uint256 _premint) {
name = _name;
symbol = _symbol;
if (_premint > 0) _mint(msg.sender, _premint);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
return transferFrom(msg.sender, to, amount);
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "insufficient");
if (from != msg.sender && allowance[from][msg.sender] != type(uint256).max) {
require(allowance[from][msg.sender] >= amount, "allowance");
allowance[from][msg.sender] -= amount;
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
receive() external payable {
balanceOf[msg.sender] += msg.value;
totalSupply += msg.value;
emit Transfer(address(0), msg.sender, msg.value);
}
}
/// @title DeployLocal — full Liquid stack on local dev node
contract DeployLocal is Script {
uint256 constant BLOCKS_PER_YEAR = 15_768_000;
function run() external {
uint256 pk = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(pk);
vm.startBroadcast(pk);
console.log("=== Deploying Liquid Protocol (local dev) ===");
console.log("Deployer:", deployer);
// 1. Deploy tokens
DevToken wlux = new DevToken("Wrapped LUX", "WLUX", 0);
console.log("WLUX:", address(wlux));
// The market's synthetic. It denominates the same asset as the
// collateral -- LUX against wrapped LUX -- because the engine converts
// underlying to debt with a decimals scalar and no price source. A
// dollar-denominated synthetic against LUX collateral would be a market
// the engine cannot value.
DevToken llux = new DevToken("Liquid LUX", "LLUX", 1_000_000 ether);
console.log("LLUX:", address(llux));
DevToken ibit = new DevToken("iShares Bitcoin ETF", "IBIT", 100_000 ether);
console.log("IBIT (mock):", address(ibit));
// 2. Deploy Liquid core
LiquidStrategyClassifier classifier = new LiquidStrategyClassifier(deployer);
console.log("Classifier:", address(classifier));
Liquid liquidImpl = new Liquid();
ERC1967Proxy liquidProxy = new ERC1967Proxy(address(liquidImpl), "");
Liquid liquid = Liquid(address(liquidProxy));
console.log("Liquid (proxy):", address(liquid));
LiquidCurator curator = new LiquidCurator(deployer, deployer);
console.log("Curator:", address(curator));
LiquidETHVault vault = new LiquidETHVault(address(wlux), address(liquid), deployer);
console.log("ETHVault:", address(vault));
LiquidPosition position = new LiquidPosition(address(liquid));
console.log("Position:", address(position));
// 3. Deploy Transmuter with LLUX as synthetic token
ILiquidTransmuter.TransmuterInitializationParams memory tParams = ILiquidTransmuter.TransmuterInitializationParams({
syntheticToken: address(llux), feeReceiver: deployer, timeToTransmute: 45 days, transmutationFee: 0.005e18, exitFee: 0.02e18, graphSize: 1000
});
LiquidTransmuter transmuter = new LiquidTransmuter(tParams);
console.log("Transmuter:", address(transmuter));
// 4. Fee vault. It holds the market's underlying token: the liquidator
// bonus is denominated in underlying, and Liquid rejects a vault
// whose token does not match.
LiquidTokenVault tokenVault = new LiquidTokenVault(address(wlux), address(liquid), deployer);
console.log("TokenVault:", address(tokenVault));
// 5. Deploy WLUX adapter (simple 1:1 price)
SecurityTokenAdapter wluxAdapter = new SecurityTokenAdapter(address(wlux), "LUX", "", "", "native", 1e18);
console.log("WLUX Adapter:", address(wluxAdapter));
// 6. Initialize Liquid
console.log("Initializing Liquid...");
LiquidInitializationParams memory initParams = LiquidInitializationParams({
admin: deployer,
debtToken: address(llux),
underlyingToken: address(wlux),
yieldToken: address(wlux),
depositCap: 10_000_000 ether,
blocksPerYear: BLOCKS_PER_YEAR,
minimumCollateralization: 1.1111e18,
// Both floors sit below the mint bar: a global floor above it marks a
// fully-drawn healthy protocol insolvent and forces full seizure.
globalMinimumCollateralization: 1.0526e18,
collateralizationLowerBound: 1.05e18,
tokenAdapter: address(wluxAdapter),
// Freezes price movement within a block, and holds it to the rate
// the collateral earns thereafter. See DeployMainnet.
maxPriceDeviation: 0.2e18 / BLOCKS_PER_YEAR,
transmuter: address(transmuter),
protocolFee: 1000, // 10% in BPS
protocolFeeReceiver: deployer,
liquidatorFee: 500, // 5% in BPS
repaymentFee: 100 // 1% in BPS
});
liquid.initialize(initParams);
liquid.setLiquidPositionNFT(address(position));
// Liquidation survives an unset fee vault; the incentive to perform one
// on a deeply underwater position does not. Seed it to exercise that path.
liquid.setLiquidFeeVault(address(tokenVault));
console.log("Liquid initialized + Position NFT and fee vault set");
// 7. Deploy SecurityTokenAdapter for IBIT
SecurityTokenAdapter ibitAdapter = new SecurityTokenAdapter(address(ibit), "IBIT", "46438F101", "US46438F1012", "ETF", 52.34e18);
console.log("IBIT Adapter:", address(ibitAdapter));
// 8. Deploy ComplianceGate
LiquidCompliance gate = new LiquidCompliance(deployer);
console.log("ComplianceGate:", address(gate));
// Compliance is now delegated to ERC-3643 IIdentityRegistry +
// ONCHAINID claim topics, configured per-vault via:
// gate.setVaultRegistry(vault, identityRegistry)
// gate.setRequiredTopics(vault, [topicId, ...])
// gate.setCountryBlock(vault, countryCode, true)
// Local dev leaves vaults unconfigured; canRedeem returns false
// until an Identity Registry is wired up.
vm.stopBroadcast();
console.log("\n=== Deployment Complete ===");
console.log("Chain ID: 1337 (local dev)");
}
}