diff --git a/.gitignore b/.gitignore index a308494..3b5594e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /cache /node_modules /out +__pycache__ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 9b5a97a..9a20b7f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/solmate"] path = lib/solmate url = https://github.com/transmissions11/solmate +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/foundry.toml b/foundry.toml index 0cced71..3cfe5e0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,14 @@ solc = "0.8.15" bytecode_hash = "none" optimizer_runs = 1000000 +no_match_test = "FFI" [profile.intense] -fuzz_runs = 10000 \ No newline at end of file +fuzz_runs = 10000 +no_match_test = "FFI" + +[profile.ffi] +ffi = true +match_test = "FFI" +no_match_test = "a^" +fuzz_runs = 1000 diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..6b4ca42 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 6b4ca42943f093642bac31783b08aa52a5a6ff64 diff --git a/test/diff_fuzz/VRGDACorrectness.t.sol b/test/diff_fuzz/VRGDACorrectness.t.sol new file mode 100644 index 0000000..db44d1f --- /dev/null +++ b/test/diff_fuzz/VRGDACorrectness.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {DSTestPlus} from "solmate/test/utils/DSTestPlus.sol"; + +import {MockLinearVRGDA} from "../mocks/MockLinearVRGDA.sol"; +import {toWadUnsafe} from "../../src/utils/SignedWadMath.sol"; +import {console} from "forge-std/console.sol"; +import {Vm} from "forge-std/Vm.sol"; + +// Differentially fuzz VRGDA solidity implementation against python reference +contract VRGDACorrectnessTest is DSTestPlus { + + //instantiate vm + address private constant VM_ADDRESS = address(bytes20(uint160(uint256(keccak256("hevm cheat code"))))); + Vm public constant vm = Vm(VM_ADDRESS); + + // sample parameters for differential fuzzing campaign + uint256 immutable MAX_TIMEFRAME = 356 days * 10; + uint256 immutable MAX_SELLABLE = 10000; + int256 immutable TARGET_PRICE = 69.42e18; + int256 immutable PRICE_DECREASE_PERCENT = 0.31e18; + int256 immutable PER_UNIT_TIME = 2e18; + + MockLinearVRGDA vrgda; + + function setUp() public { + vrgda = new MockLinearVRGDA(TARGET_PRICE, PRICE_DECREASE_PERCENT, PER_UNIT_TIME); + } + + // test correctness of implementation for a single input, as a sanity check + function testFFICorrectness() public { + // 10 days in wads + uint256 timeSinceStart = 10 * 1e18; + // number sold, slightly ahead of schedule + uint256 numSold = 25; + + uint256 actualPrice = vrgda.getVRGDAPrice(int256(timeSinceStart), numSold); + uint256 expectedPrice = calculatePrice( + TARGET_PRICE, + PRICE_DECREASE_PERCENT, + PER_UNIT_TIME, + timeSinceStart, + numSold + ); + + console.log("actual price", actualPrice); + console.log("expected price", expectedPrice); + //check approximate equality + assertRelApproxEq(expectedPrice, actualPrice, 0.00001e18); + // sanity check that prices are greater than zero + assertGt(actualPrice, 0); + } + + + // fuzz to test correctness against multiple inputs + function testFFICorrectnessFuzz(uint256 timeSinceStart, uint256 numSold) public { + // Bound fuzzer inputs to acceptable contraints. + numSold = bound(numSold, 0, MAX_SELLABLE); + timeSinceStart = bound(timeSinceStart, 0, MAX_TIMEFRAME); + // Convert to wad days for convenience. + timeSinceStart = timeSinceStart * 1e18 / 1 days; + + // We wrap this call in a try catch because the getVRGDAPrice is expected to revert for + // degenerate cases. When this happens, we just continue campaign. + try vrgda.getVRGDAPrice(int256(timeSinceStart), numSold) returns (uint256 actualPrice) { + uint256 expectedPrice = calculatePrice( + TARGET_PRICE, + PRICE_DECREASE_PERCENT, + PER_UNIT_TIME, + timeSinceStart, + numSold + ); + if (expectedPrice < 0.0000001e18) return; // For really small prices, we expect divergence, so we skip + assertRelApproxEq(expectedPrice, actualPrice, 0.00001e18); + } catch {} + } + + + + + // ffi call + function calculatePrice( + int256 _targetPrice, + int256 _priceDecreasePercent, + int256 _perUnitTime, + uint256 _timeSinceStart, + uint256 _numSold + ) private returns (uint256) { + string[] memory inputs = new string[](13); + inputs[0] = "python3"; + inputs[1] = "test/diff_fuzz/python/compute_price.py"; + inputs[2] = "linear"; + inputs[3] = "--time_since_start"; + inputs[4] = vm.toString(_timeSinceStart); + inputs[5] = "--num_sold"; + inputs[6] = vm.toString(_numSold); + inputs[7] = "--target_price"; + inputs[8] = vm.toString(uint256(_targetPrice)); + inputs[9] = "--price_decrease_percent"; + inputs[10] = vm.toString(uint256(_priceDecreasePercent)); + inputs[11] = "--per_time_unit"; + inputs[12] = vm.toString(uint256(_perUnitTime)); + + return abi.decode(vm.ffi(inputs), (uint256)); + } +} diff --git a/test/diff_fuzz/python/VRGDA.py b/test/diff_fuzz/python/VRGDA.py new file mode 100644 index 0000000..657f7d3 --- /dev/null +++ b/test/diff_fuzz/python/VRGDA.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +import math + + +class VRGDA(ABC): + def __init__(self, target_price, price_decrease_percent): + self.target_price = target_price + self.price_decrease_percent = price_decrease_percent + + @abstractmethod + def get_price(self, time_since_start, num_sold): + pass + + +class LinearVRGDA(VRGDA): + def __init__(self, target_price, price_decrease_percent, per_time_unit): + super().__init__(target_price, price_decrease_percent) + self.per_unit_time = per_time_unit + + def get_price(self, time_since_start, num_sold): + num_periods = time_since_start - num_sold / self.per_unit_time + decay_constant = 1 - self.price_decrease_percent + scale_factor = math.pow(decay_constant, num_periods) + + return self.target_price * scale_factor \ No newline at end of file diff --git a/test/diff_fuzz/python/compute_price.py b/test/diff_fuzz/python/compute_price.py new file mode 100644 index 0000000..2c03a00 --- /dev/null +++ b/test/diff_fuzz/python/compute_price.py @@ -0,0 +1,39 @@ +from VRGDA import LinearVRGDA +from eth_abi import encode_single +import argparse + +def main(args): + if (args.type == 'linear'): + calculate_linear_vrgda_price(args) + +def calculate_linear_vrgda_price(args): + vrgda = LinearVRGDA( + args.target_price / (10 ** 18), ## scale decimals + args.price_decrease_percent / (10 ** 18), ## scale decimals + args.per_time_unit / (10 ** 18), ## scale decimals + ) + price = vrgda.get_price( + args.time_since_start / (10 ** 18), ##scale decimals + args.num_sold + 1 ## price of next item + ) + price *= (10 ** 18) ## scale up + encode_and_print(price) + +def encode_and_print(price): + enc = encode_single('uint256', int(price)) + ## append 0x for FFI parsing + print("0x" + enc.hex()) + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("type", choices=["linear"]) + parser.add_argument("--time_since_start", type=int) + parser.add_argument("--num_sold", type=int) + parser.add_argument("--target_price", type=int) + parser.add_argument("--price_decrease_percent", type=int) + parser.add_argument("--per_time_unit", type=int) + return parser.parse_args() + +if __name__ == '__main__': + args = parse_args() + main(args) \ No newline at end of file diff --git a/test/diff_fuzz/python/requirements.txt b/test/diff_fuzz/python/requirements.txt new file mode 100644 index 0000000..dd0d11f --- /dev/null +++ b/test/diff_fuzz/python/requirements.txt @@ -0,0 +1,8 @@ +cytoolz==0.12.0 +eth-abi==3.0.1 +eth-hash==0.3.3 +eth-typing==3.1.0 +eth-utils==2.0.0 +parsimonious==0.8.1 +six==1.16.0 +toolz==0.12.0