From 357d9fa32e6941af961a21b15ed495c87c585aab Mon Sep 17 00:00:00 2001 From: Kevin Britz Date: Tue, 7 Apr 2026 22:38:43 -0700 Subject: [PATCH 1/4] Fix missing emission Jacobian in VRGDA exponential decay integral Co-Authored-By: Claude Opus 4.6 (1M context) --- src/vrgda/VRGDADecayMath.sol | 19 ++++--- src/vrgda/types/LinearExponentialVRGDA.sol | 2 + test/vrgda/LinearExponentialVRGDA.t.sol | 42 +++++++-------- test/vrgda/VRGDAMath.t.sol | 61 ++++++++++++++-------- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/src/vrgda/VRGDADecayMath.sol b/src/vrgda/VRGDADecayMath.sol index d705dca..90d90ab 100644 --- a/src/vrgda/VRGDADecayMath.sol +++ b/src/vrgda/VRGDADecayMath.sol @@ -11,36 +11,41 @@ library VRGDADecayMath { } /// @notice Returns the cost of a purchase over a continuous VRGDA with exponential decay + /// @dev Integrates the VRGDA price curve over token amount. Since from/to are in auction-time, + /// the change of variables from token-space introduces a Jacobian factor of emission (dn = emission * dt). /// @param timestamp The timestamp of the start of the VRGDA - /// @param price The price coefficient of the VRGDA + /// @param price The target price per token at neutral schedule /// @param decay The decay coefficient of the VRGDA + /// @param emission The issuance rate (tokens per day) /// @param from Where the VRGDA has sold up to in its issuance schedule as of current block.timestamp (days) /// @param to Where the VRGDA will have sold up to in its issuance schedule after the current purchase (days) /// @return cost The cost of the purchase - function exponentialDecay(UFixed18 timestamp, UFixed18 price, UFixed18 decay, UFixed18 from, UFixed18 to) internal view returns (UFixed18 cost) { + function exponentialDecay(UFixed18 timestamp, UFixed18 price, UFixed18 decay, UFixed18 emission, UFixed18 from, UFixed18 to) internal view returns (UFixed18 cost) { (Fixed18 a, Fixed18 b) = (convert(timestamp + to), convert(timestamp + from)); Fixed18 sDecay = Fixed18Lib.from(decay); - return price * UFixed18Lib.from((-sDecay * a).exp() - (-sDecay * b).exp()) / decay; + return price * emission * UFixed18Lib.from((-sDecay * a).exp() - (-sDecay * b).exp()) / decay; } /// @notice Returns time of the latest auction after the purchase over a continuous VRGDA with exponential decay + /// @dev Solves the corrected forward formula: cost = price * emission * (exp(-decay*a) - exp(-decay*b)) / decay /// @param timestamp The timestamp of the start of the VRGDA - /// @param price The price coefficient of the VRGDA + /// @param price The target price per token at neutral schedule /// @param decay The decay coefficient of the VRGDA + /// @param emission The issuance rate (tokens per day) /// @param from The time of the latest auction relative to the start of the VRGDA /// @param cost The cost of the purchase /// @return to The time of the latest auction after the purchase relative to the start of the VRGDA - function exponentialDecayI(UFixed18 timestamp, UFixed18 price, UFixed18 decay, UFixed18 from, UFixed18 cost) internal view returns (UFixed18 to) { + function exponentialDecayI(UFixed18 timestamp, UFixed18 price, UFixed18 decay, UFixed18 emission, UFixed18 from, UFixed18 cost) internal view returns (UFixed18 to) { Fixed18 b = convert(timestamp + from); - (Fixed18 sDecay, Fixed18 sPrice, Fixed18 sCost) = (Fixed18Lib.from(decay), Fixed18Lib.from(price), Fixed18Lib.from(cost)); + (Fixed18 sDecay, Fixed18 sPrice, Fixed18 sCost, Fixed18 sEmission) = (Fixed18Lib.from(decay), Fixed18Lib.from(price), Fixed18Lib.from(cost), Fixed18Lib.from(emission)); // increase precision by inverting the input to exp() if b is negative Fixed18 exp = b >= Fixed18Lib.ZERO ? sPrice / (sDecay * b).exp() : sPrice * (-sDecay * b).exp(); - Fixed18 a = ln(sPrice, (sCost * sDecay + exp)) / sDecay; + Fixed18 a = ln(sPrice, (sCost * sDecay / sEmission + exp)) / sDecay; return convert(a) - timestamp; } diff --git a/src/vrgda/types/LinearExponentialVRGDA.sol b/src/vrgda/types/LinearExponentialVRGDA.sol index 5426dd8..9abb772 100644 --- a/src/vrgda/types/LinearExponentialVRGDA.sol +++ b/src/vrgda/types/LinearExponentialVRGDA.sol @@ -25,6 +25,7 @@ library LinearExponentialVRGDALib { self.timestamp, self.price, self.decay, + self.emission, VRGDAIssuanceMath.linearIssuanceI(self.emission, issued), VRGDAIssuanceMath.linearIssuanceI(self.emission, issued + amount) ); @@ -42,6 +43,7 @@ library LinearExponentialVRGDALib { self.timestamp, self.price, self.decay, + self.emission, VRGDAIssuanceMath.linearIssuanceI(self.emission, issued), cost ) diff --git a/test/vrgda/LinearExponentialVRGDA.t.sol b/test/vrgda/LinearExponentialVRGDA.t.sol index e19457d..a3dc541 100644 --- a/test/vrgda/LinearExponentialVRGDA.t.sol +++ b/test/vrgda/LinearExponentialVRGDA.t.sol @@ -20,73 +20,73 @@ contract LinearExponentialVRGDATest is RootTest { function test_costDecreasesWhenBehind() public { // initial cost to purchase 1 token quite high - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1)), UFixed18.wrap(11_291.903496976_577338030e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1)), UFixed18.wrap(2_258_380.699395315_467606000e18)); // cost to move ahead of issuance schedule is higher - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(32_466_151.192919613_034886620e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(6_493_230_238.583922606_977324000e18)); // after 1 day with nothing sold, cost to purchase 100 tokens is reasonable, and purchase is made skip(1 days); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(1_473.960983816_764210610e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(294_792.196763352_842122000e18)); issued = UFixed18Lib.from(100); // price increases as a result, but we're still behind - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(218_755.206002187_764655650e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(43_751_041.200437552_931130000e18)); // after half a day, price becomes reasonable again, and continues to decrease skip(12 hours); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(1_473.960983816_764210610e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(294_792.196763352_842122000e18)); skip(12 hours); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(9.931470987_677229170e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(1_986.294197535_445834000e18)); } function test_amountIncreasesWhenBehind() public { // after 1 day, 200 tokens have been purchased, even with issuance schedule skip(1 days); issued = UFixed18Lib.from(200); - // a 5k purchase would buy us 200 tokens - assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(5_000)), UFixed18.wrap(0.448974472_612174000e18)); + // a 5k purchase buys tokens + assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(5_000)), UFixed18.wrap(0.002270130_392229200e18)); // 8 hours later we would be behind issuance schedule, and should be able to purchase more tokens skip(8 hours); - assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(5_000)), UFixed18.wrap(9.849858676_957755400e18)); + assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(5_000)), UFixed18.wrap(0.063538021_305059800e18)); // after 4 days, should be able to purchase considerably more skip(3 days + 16 hours); - assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(5_000)), UFixed18.wrap(724.294476783_258653200e18)); + assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(5_000)), UFixed18.wrap(618.328129452_298664400e18)); } function test_amountDecreasesWhenAhead() public { // after 1 day, 200 tokens have been purchased, even with issuance schedule skip(1 days); issued = UFixed18Lib.from(200); - // a 50k purchase would buy us 4 tokens - assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(50_000)), UFixed18.wrap(4.091865860_077915200e18)); + // a 50k purchase buys tokens + assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(50_000)), UFixed18.wrap(0.022689716_894178400e18)); // if someone purchases 100 tokens, the same 50k purchase would buy us almost nothing issued = issued + UFixed18Lib.from(100); - assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(50_000)), UFixed18.wrap(0.030570397_153121600e18)); + assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(50_000)), UFixed18.wrap(0.000152968_278971800e18)); // if even more overbought, the amount we can purchase becomes infinitesimal issued = issued + UFixed18Lib.from(200); - assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(50_000)), UFixed18.wrap(0.000001388_955087400e18)); + assertUFixed18Eq(vrgda.toAmount(issued, UFixed18Lib.from(50_000)), UFixed18.wrap(0.000000006_944775400e18)); } function test_costIncreasesWhenAhead() public { // after 1 day, 200 tokens have been purchased, even with issuance schedule skip(1 days); issued = UFixed18Lib.from(200); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1)), UFixed18.wrap(11_291.903496976_577338030e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1)), UFixed18.wrap(2_258_380.699395315_467606000e18)); // price to purchase ahead of issuance schedule is higher - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(32_466_151.192919613_034886620e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(6_493_230_238.583922606_977324000e18)); // after 3 days, expected issuance is 600 tokens, and we've sold 650, slightly ahead of schedule issued = issued + UFixed18Lib.from(450); skip(2 days); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(395_518_690.835029055_174152540e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(79_103_738_167.005811034_830508000e18)); // a day later we're now at 900, when 800 was expected; cost continues to increase issued = issued + UFixed18Lib.from(250); skip(1 days); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(4_818_404_062.443085713_321147380e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(963_680_812_488.617142664_229476000e18)); } function test_toCostEquivalentWithToAmount() public { @@ -107,17 +107,17 @@ contract LinearExponentialVRGDATest is RootTest { // we're 3 days into the auction, and have only sold 50 tokens; cost to purchase 100 tokens is quite low skip(3 days); issued = UFixed18Lib.from(50); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(0.000037011_147859640e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(0.007402229_571928000e18)); // another day passes, and price for 100 tokens hits zero, but 500 tokens has some cost skip(3 days); assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(100)), UFixed18.wrap(0)); - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(500)), UFixed18.wrap(1_691702110)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(500)), UFixed18.wrap(338340422000)); // user grabs 100 tokens for free issued = issued + UFixed18Lib.from(100); // we're 6 days in, and should have sold 1200 tokens; price recovers upon doing so - assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1050)), UFixed18.wrap(220_239.165828664_098470190e18)); + assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1050)), UFixed18.wrap(44_047_833.165732819_694038000e18)); } /// @dev Ensures that users may not use split purchases into smaller batches to reduce cost diff --git a/test/vrgda/VRGDAMath.t.sol b/test/vrgda/VRGDAMath.t.sol index b55e6bd..b91ac12 100644 --- a/test/vrgda/VRGDAMath.t.sol +++ b/test/vrgda/VRGDAMath.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; -import { UFixed18 } from "../../src/number/types/UFixed18.sol"; +import { UFixed18, UFixed18Lib } from "../../src/number/types/UFixed18.sol"; import { VRGDADecayMath } from "../../src/vrgda/VRGDADecayMath.sol"; import { VRGDAIssuanceMath } from "../../src/vrgda/VRGDAIssuanceMath.sol"; import { RootTest } from "../RootTest.sol"; @@ -27,10 +27,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.05e18), UFixed18.wrap(0.1e18) ), - UFixed18.wrap(1.447492810230124920e18), + UFixed18.wrap(289.498562046024984000e18), "incorrect result with from and to in past" ); // timestamp = 20_000, lamba = 10, k = 100, T = 0 -> 0.1 @@ -39,10 +40,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.1e18), UFixed18.wrap(0.2e18) ), - UFixed18.wrap(6.321205588285576790e18), + UFixed18.wrap(1264.241117657115358000e18), "incorrect result with from in past and zero to" ); // timestamp = 20_000, lamba = 10, k = 100, T = -0.1 -> 0 @@ -51,10 +53,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.2e18), UFixed18.wrap(0.3e18) ), - UFixed18.wrap(17.182818284590452340e18), + UFixed18.wrap(3436.563656918090468000e18), "incorrect result with zero from and to in future" ); // timestamp = 20_000, lamba = 10, k = 100, T = -0.15 -> -0.1 @@ -63,10 +66,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.3e18), UFixed18.wrap(0.35e18) ), - UFixed18.wrap(17.634072418790195840e18), + UFixed18.wrap(3526.814483758039168000e18), "incorrect result with from and to in future" ); } @@ -78,10 +82,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(1e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), UFixed18.wrap(0.1e18) ), - UFixed18.wrap(10.517091807564762400e18), + UFixed18.wrap(2103.418361512952480000e18), "incorrect result with lower decay" ); // timestamp = 20_000, lamba = 100, k = 100, T = -0.1 -> 0 @@ -90,10 +95,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(100e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), UFixed18.wrap(0.1e18) ), - UFixed18.wrap(22025.465794806716461725e18), + UFixed18.wrap(4405093.158961343292345000e18), "incorrect result with higher decay" ); } @@ -105,10 +111,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(10e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), UFixed18.wrap(0.1e18) ), - UFixed18.wrap(1.718281828459045234e18), + UFixed18.wrap(343.656365691809046800e18), "incorrect result with lower initial price" ); // timestamp = 20_000, lamba = 10, k = 1000, T = -0.1 -> 0 @@ -117,10 +124,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(1000e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), UFixed18.wrap(0.1e18) ), - UFixed18.wrap(171.828182845904523400e18), + UFixed18.wrap(34365.636569180904680000e18), "incorrect result with higher initial price" ); } @@ -136,10 +144,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.1e18), UFixed18.wrap(0.2e18) ), - UFixed18.wrap(2120528_238158320), // 0.002120528_238158320 + UFixed18.wrap(424_105_647_631_664_000), // 0.424105647_631664000 "incorrect result while somewhat behind" ); @@ -150,6 +159,7 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.2e18), UFixed18.wrap(0.21e18) ), @@ -164,10 +174,11 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.2e18), UFixed18.wrap(5.4e18) ), - UFixed18.wrap(545.981500331_442390190e18), // 545.981500331_442390190 + UFixed18.wrap(109196.300066288_478038000e18), // 109196.300066288_478038000 "incorrect result after recovering" ); } @@ -178,9 +189,9 @@ contract VRGDAMathTest is RootTest { price = boundUFixed18(price, UFixed18.wrap(1e12), UFixed18.wrap(1e24)); from = boundUFixed18(from, UFixed18.wrap(1e12), UFixed18.wrap(20e18)); to = boundUFixed18(to, from, UFixed18.wrap(20e18)); - UFixed18 cost = VRGDADecayMath.exponentialDecay(UFixed18.wrap(20_000e18), price, decay, from, to); // not revert + UFixed18 cost = VRGDADecayMath.exponentialDecay(UFixed18.wrap(20_000e18), price, decay, UFixed18Lib.from(200), from, to); // not revert if (cost < UFixed18.wrap(1e18)) return; // skip under minimum purchase amount - UFixed18 to2 = VRGDADecayMath.exponentialDecayI(UFixed18.wrap(20_000e18), price, decay, from, cost); // not revert + UFixed18 to2 = VRGDADecayMath.exponentialDecayI(UFixed18.wrap(20_000e18), price, decay, UFixed18Lib.from(200), from, cost); // not revert assertApproxEqRel(UFixed18.unwrap(to), UFixed18.unwrap(to2), 1e13, "result too far off"); } @@ -192,8 +203,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.05e18), - UFixed18.wrap(1.447492810230124920e18) + UFixed18.wrap(289.498562046024984000e18) ), UFixed18.wrap(0.1e18 + 1), // rounding error size (+1) "incorrect result with from and to in past" @@ -204,8 +216,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.1e18), - UFixed18.wrap(6.321205588285576790e18) + UFixed18.wrap(1264.241117657115358000e18) ), UFixed18.wrap(0.2e18), "incorrect result with from in past and zero to" @@ -216,8 +229,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.2e18), - UFixed18.wrap(17.182818284590452340e18) + UFixed18.wrap(3436.563656918090468000e18) ), UFixed18.wrap(0.3e18 - 1), // rounding error size (-1) "incorrect result with zero from and to in future" @@ -228,8 +242,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0.3e18), - UFixed18.wrap(17.634072418790195840e18) + UFixed18.wrap(3526.814483758039168000e18) ), UFixed18.wrap(0.35e18 - 1), // rounding error size (-1) "incorrect result with from and to in future" @@ -243,8 +258,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(1e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), - UFixed18.wrap(10.517091807564762400e18) + UFixed18.wrap(2103.418361512952480000e18) ), UFixed18.wrap(0.1e18 - 11), // rounding error size (-11) "incorrect result with lower decay" @@ -255,8 +271,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(100e18), UFixed18.wrap(100e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), - UFixed18.wrap(22025.465794806716461725e18) + UFixed18.wrap(4405093.158961343292345000e18) ), UFixed18.wrap(0.1e18 - 1), // rounding error size (-1) "incorrect result with higher decay" @@ -270,8 +287,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(10e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), - UFixed18.wrap(1.718281828459045234e18) + UFixed18.wrap(343.656365691809046800e18) ), UFixed18.wrap(0.1e18 - 1), // rounding error size (-1) "incorrect result with lower initial price" @@ -282,8 +300,9 @@ contract VRGDAMathTest is RootTest { UFixed18.wrap(20_000e18), UFixed18.wrap(1000e18), UFixed18.wrap(10e18), + UFixed18Lib.from(200), UFixed18.wrap(0e18), - UFixed18.wrap(171.828182845904523400e18) + UFixed18.wrap(34365.636569180904680000e18) ), UFixed18.wrap(0.1e18 - 1), // rounding error size (-1) "incorrect result with higher initial price" From 46a483a4330c563abc16eb9bd480739434808af7 Mon Sep 17 00:00:00 2001 From: Kevin Britz Date: Tue, 7 Apr 2026 22:54:17 -0700 Subject: [PATCH 2/4] Tighten fuzz price bound to prevent overflow from emission multiplier --- test/vrgda/VRGDAMath.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/vrgda/VRGDAMath.t.sol b/test/vrgda/VRGDAMath.t.sol index b91ac12..75c84f9 100644 --- a/test/vrgda/VRGDAMath.t.sol +++ b/test/vrgda/VRGDAMath.t.sol @@ -186,7 +186,7 @@ contract VRGDAMathTest is RootTest { function test_exponentialDecay_fuzz(UFixed18 price, UFixed18 decay, UFixed18 from, UFixed18 to) external { skip(86400 * 10); // 10 days decay = boundUFixed18(decay, UFixed18.wrap(1e12), UFixed18.wrap(8e18)); - price = boundUFixed18(price, UFixed18.wrap(1e12), UFixed18.wrap(1e24)); + price = boundUFixed18(price, UFixed18.wrap(1e12), UFixed18.wrap(5e21)); // reduced by emission factor (200) from = boundUFixed18(from, UFixed18.wrap(1e12), UFixed18.wrap(20e18)); to = boundUFixed18(to, from, UFixed18.wrap(20e18)); UFixed18 cost = VRGDADecayMath.exponentialDecay(UFixed18.wrap(20_000e18), price, decay, UFixed18Lib.from(200), from, to); // not revert From 07e4dc15db1b4b00d905264c4c2be03311f3dc17 Mon Sep 17 00:00:00 2001 From: Kevin Britz Date: Tue, 7 Apr 2026 23:11:38 -0700 Subject: [PATCH 3/4] Add neutral-time price test to verify emission Jacobian correctness --- test/vrgda/LinearExponentialVRGDA.t.sol | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/vrgda/LinearExponentialVRGDA.t.sol b/test/vrgda/LinearExponentialVRGDA.t.sol index a3dc541..57a0919 100644 --- a/test/vrgda/LinearExponentialVRGDA.t.sol +++ b/test/vrgda/LinearExponentialVRGDA.t.sol @@ -18,6 +18,25 @@ contract LinearExponentialVRGDATest is RootTest { }); } + /// @notice At neutral time (exactly on schedule), the marginal cost of a small purchase + /// should approximate price * amount, confirming the emission Jacobian is correct. + function test_neutralTimePriceEqualsTarget() public { + // set up a clean neutral-time scenario: + // warp so that time() - timestamp = 1 day exactly, with issued = emission * 1 day = 200 tokens + vm.warp(86400 + 86400); // time() = 2.0 days, timestamp = 1.0 day, elapsed = 1.0 day + issued = UFixed18Lib.from(200); // exactly on schedule + + // buy a small amount (0.001 tokens) — marginal cost should be ≈ price * amount = 100 * 0.001 = 0.1 + UFixed18 smallAmount = UFixed18.wrap(0.001e18); + UFixed18 cost = vrgda.toCost(issued, smallAmount); + // expected ≈ 0.1e18, allow 10% tolerance for the small-but-finite purchase size + assertApproxEqRel(UFixed18.unwrap(cost), 0.1e18, 0.1e18, "neutral time price should approximate price * amount"); + + // confirm the inverse: spending that cost should yield ≈ smallAmount tokens + UFixed18 recovered = vrgda.toAmount(issued, cost); + assertApproxEqRel(UFixed18.unwrap(recovered), UFixed18.unwrap(smallAmount), 0.1e18, "neutral time toAmount(toCost(x)) should approximate x"); + } + function test_costDecreasesWhenBehind() public { // initial cost to purchase 1 token quite high assertUFixed18Eq(vrgda.toCost(issued, UFixed18Lib.from(1)), UFixed18.wrap(2_258_380.699395315_467606000e18)); From f5376836ffe2feaa9000cb916fd6eea0b6832cce Mon Sep 17 00:00:00 2001 From: Kevin Britz Date: Wed, 8 Apr 2026 14:00:56 -0700 Subject: [PATCH 4/4] Fix precision loss in exponentialDecayI by scaling ln ratio instead of dividing by emission --- src/vrgda/VRGDADecayMath.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vrgda/VRGDADecayMath.sol b/src/vrgda/VRGDADecayMath.sol index 90d90ab..497098c 100644 --- a/src/vrgda/VRGDADecayMath.sol +++ b/src/vrgda/VRGDADecayMath.sol @@ -45,7 +45,9 @@ library VRGDADecayMath { // increase precision by inverting the input to exp() if b is negative Fixed18 exp = b >= Fixed18Lib.ZERO ? sPrice / (sDecay * b).exp() : sPrice * (-sDecay * b).exp(); - Fixed18 a = ln(sPrice, (sCost * sDecay / sEmission + exp)) / sDecay; + // scale both sides of the ln ratio by emission to avoid precision loss from / sEmission + // ln(price, cost*decay/emission + exp) = ln(price*emission, cost*decay + exp*emission) + Fixed18 a = ln(sPrice * sEmission, (sCost * sDecay + exp * sEmission)) / sDecay; return convert(a) - timestamp; }