diff --git a/cadence/tests/evm_state_helpers.cdc b/cadence/tests/evm_state_helpers.cdc index dff7a128..9e9711a2 100644 --- a/cadence/tests/evm_state_helpers.cdc +++ b/cadence/tests/evm_state_helpers.cdc @@ -30,6 +30,24 @@ access(all) fun setVaultSharePrice( /// Set Uniswap V3 pool to a specific price via EVM.store /// Creates pool if it doesn't exist, then manipulates state /// Price is specified as UFix128 for high precision (24 decimal places) +/// +/// Example: Target FLOW price is $0.50 (1 FLOW = 0.50 PYUSD), swap should yield exactly 0.5 PYUSD per FLOW after fees +/// +/// let targetPrice = 0.5 // 1 WFLOW = 0.5 PYUSD +/// let fee: UInt64 = 3000 // 0.3% fee tier +/// +/// setPoolToPrice( +/// factoryAddress: factoryAddress, +/// tokenAAddress: wflowAddress, +/// tokenBAddress: pyusd0Address, +/// fee: fee, +/// // Use feeAdjustedPrice to ensure swap output equals target after fees +/// priceTokenBPerTokenA: feeAdjustedPrice(targetPrice, fee: fee, reverse: false), +/// tokenABalanceSlot: wflowBalanceSlot, +/// tokenBBalanceSlot: pyusd0BalanceSlot, +/// signer: testAccount +/// ) +/// // Now swapping 100 WFLOW → exactly 50 PYUSD (fee already compensated in pool price) access(all) fun setPoolToPrice( factoryAddress: String, tokenAAddress: String, @@ -54,8 +72,24 @@ access(all) fun setPoolToPrice( /* --- Fee Adjustment --- */ /// Adjust a pool price to compensate for Uniswap V3 swap fees. -/// Forward: price / (1 - fee/1e6) -/// Reverse: price * (1 - fee/1e6) +/// +/// When swapping on Uniswap V3, the output is reduced by the pool fee. +/// This function pre-adjusts the pool price so that swaps yield exact target amounts. +/// +/// Forward (reverse: false): price / (1 - fee/1e6) +/// - Use when swapping A→B (forward direction) +/// - Inflates pool price so output after fee equals target +/// - Example: targetPrice=1.0, fee=3000 (0.3%) +/// setPoolPrice = 1.0 / 0.997 = 1.003009... +/// swapOutput = 1.003009 × 0.997 = 1.0 ✓ +/// +/// Reverse (reverse: true): price * (1 - fee/1e6) +/// - Use when swapping B→A (reverse direction) +/// - Deflates pool price to compensate for fee on reverse path +/// - Example: targetPrice=2.0, fee=3000 (0.3%) +/// setPoolPrice = 2.0 × 0.997 = 1.994 +/// For B→A swap: output = amountIn / 1.994 × 0.997 ≈ amountIn / 2.0 ✓ +/// /// Computed in UFix128 for full 24-decimal-place precision. access(all) fun feeAdjustedPrice(_ price: UFix128, fee: UInt64, reverse: Bool): UFix128 { let feeRate = UFix128(fee) / 1_000_000.0 diff --git a/cadence/tests/evm_state_helpers_test.cdc b/cadence/tests/evm_state_helpers_test.cdc index a432fcd7..9765e171 100644 --- a/cadence/tests/evm_state_helpers_test.cdc +++ b/cadence/tests/evm_state_helpers_test.cdc @@ -30,9 +30,18 @@ access(all) let pyusd0VaultTypeId = "A.1e4aa0b87d10b141.EVMVMBridgedToken_99af3e // Vault public paths access(all) let pyusd0PublicPath = /public/EVMVMBridgedToken_99af3eea856556646c98c8b9b2548fe815240750Vault access(all) let fusdevPublicPath = /public/EVMVMBridgedToken_d069d989e2f44b70c65347d1853c0c67e10a9f8dVault +access(all) let wflowPublicPath = /public/EVMVMBridgedToken_d3bf53dac106a0290b0483ecbc89d40fcc961f3eVault +// When WFLOW is bridged back to Cadence, it becomes FlowToken (not a bridged token) +access(all) let flowTokenPublicPath = /public/flowTokenReceiver access(all) let univ3PoolFee: UInt64 = 3000 +// Fee tiers for testing (in basis points / 100 = percentage) +// 100 = 0.01%, 500 = 0.05%, 3000 = 0.3% +access(all) let feeTier100: UInt64 = 100 +access(all) let feeTier500: UInt64 = 500 +access(all) let feeTier3000: UInt64 = 3000 + access(all) var snapshot: UInt64 = 0 access(all) var testAccount = Test.createAccount() @@ -154,3 +163,387 @@ fun test_ERC4626PriceSetAndDeposit() { log("Multiplier \(multiplier): expected=\(expectedShares) actual=\(fusdevBalance)") } } + +// ============================================================================= +// Fee-Adjusted Price Tests +// These tests verify the actual usage patterns in forked_rebalance_*_test.cdc +// ============================================================================= + +/// Test forward fee-adjusted price: pre-adjust pool price so swap output equals exact target +/// Pattern: setPoolToPrice(priceTokenBPerTokenA: feeAdjustedPrice(targetPrice, fee, reverse: false)) +/// When swapping A→B, the output should equal targetPrice × amountIn exactly (not fee-reduced) +access(all) +fun test_UniswapV3ForwardFeeAdjustedPrice() { + let targetPrices = [0.5, 1.0, 2.0, 3.0, 5.0] + let flowAmount = 10000.0 + + for targetPrice in targetPrices { + Test.reset(to: snapshot) + + // Pre-adjust price: set pool price = targetPrice / (1 - fee) + // So when swapping, output = poolPrice × (1 - fee) = targetPrice + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(targetPrice), fee: univ3PoolFee, reverse: false), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + let balanceBefore = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + + // Swap WFLOW → PYUSD0 (forward direction) + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, wflowAddress, pyusd0Address, univ3PoolFee, flowAmount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let balanceAfter = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + let swapOutput = balanceAfter - balanceBefore + // Expected output should be exactly targetPrice × flowAmount (no fee reduction) + let expectedOut = UFix128(targetPrice) * UFix128(flowAmount) + + let tolerance = 0.000001 + Test.assert( + equalAmounts(a: UFix64(swapOutput), b: UFix64(expectedOut), tolerance: tolerance), + message: "Forward fee-adjusted price \(targetPrice): swap output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Forward fee-adjusted price \(targetPrice): expected=\(expectedOut) actual=\(swapOutput)") + } +} + +/// Test reverse fee-adjusted price: pre-adjust pool price for reverse swap direction +/// Pattern: setPoolToPrice(priceTokenBPerTokenA: feeAdjustedPrice(targetPrice, fee, reverse: true)) +/// When swapping B→A (reverse direction), the output should equal amountIn / targetPrice exactly +access(all) +fun test_UniswapV3ReverseFeeAdjustedPrice() { + let targetPrices = [0.5, 1.0, 2.0, 3.0, 5.0] + let pyusdAmount = 10000.0 + + for targetPrice in targetPrices { + Test.reset(to: snapshot) + + // Pre-adjust price with reverse=true for B→A swap direction + // For reverse swaps, we deflate the price: poolPrice = targetPrice × (1 - fee) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(targetPrice), fee: univ3PoolFee, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + let balanceBefore = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath) ?? 0.0 + + // Swap PYUSD0 → WFLOW (reverse direction relative to pool's A→B) + // Use generic swap transaction that dynamically resolves input token vault + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap_generic.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, pyusd0Address, wflowAddress, univ3PoolFee, pyusdAmount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let balanceAfter = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath)! + let swapOutput = balanceAfter - balanceBefore + // For reverse swap: PYUSD0 → WFLOW, output = amountIn / priceTokenBPerTokenA + // With reverse fee adjustment, output should be pyusdAmount / targetPrice + let expectedOut = UFix128(pyusdAmount) / UFix128(targetPrice) + + let tolerance = 0.000001 + Test.assert( + equalAmounts(a: UFix64(swapOutput), b: UFix64(expectedOut), tolerance: tolerance), + message: "Reverse fee-adjusted price \(targetPrice): swap output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Reverse fee-adjusted price \(targetPrice): expected=\(expectedOut) actual=\(swapOutput)") + } +} + +/// Test round-trip swap: swap forward then backward without resetting pool price +/// Verifies that after a round-trip, the balance reflects fees paid twice +/// Round-trip: WFLOW → PYUSD0 → WFLOW +/// Expected final amount ≈ original × (1 - fee)² (fees deducted on each swap) +access(all) +fun test_UniswapV3RoundTripSwap() { + let prices = [0.5, 1.0, 2.0, 3.0, 5.0] + let initialAmount = 5000.0 + + for price in prices { + Test.reset(to: snapshot) + + // Set pool price once (no fee adjustment - we want to observe natural fee behavior) + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: UFix128(price), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + // Record initial WFLOW balance (from FlowToken, not bridged WFLOW) + let wflowBalanceInitial = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath) ?? 0.0 + let pyusdBalanceInitial = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + + // === Step 1: Swap WFLOW → PYUSD0 === + let forwardSwapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, wflowAddress, pyusd0Address, univ3PoolFee, initialAmount] + ) + ) + Test.expect(forwardSwapRes, Test.beSucceeded()) + + let pyusdBalanceAfterForward = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + let pyusdReceived = pyusdBalanceAfterForward - pyusdBalanceInitial + + // Forward swap output should be: initialAmount × price × (1 - fee) + let expectedPyusdReceived = UFix128(initialAmount) * UFix128(price) * (1.0 - UFix128(univ3PoolFee) / 1_000_000.0) + log("Round-trip price=\(price) Step 1: WFLOW→PYUSD0: sent=\(initialAmount) received=\(pyusdReceived) expected=\(expectedPyusdReceived)") + + // === Step 2: Swap all PYUSD0 back → WFLOW === + // Use generic swap transaction that dynamically resolves input token vault + let reverseSwapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap_generic.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, pyusd0Address, wflowAddress, univ3PoolFee, pyusdReceived] + ) + ) + Test.expect(reverseSwapRes, Test.beSucceeded()) + + let wflowBalanceFinal = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath)! + // Calculate net returned: (final + initialAmount) - initial + // wflowBalanceFinal = wflowBalanceInitial - initialAmount + wflowReturned + // So: wflowReturned = wflowBalanceFinal + initialAmount - wflowBalanceInitial + // Reorder to avoid underflow: (final + spent) - initial + let wflowReturned = (wflowBalanceFinal + initialAmount) - wflowBalanceInitial + + // Round-trip: started with initialAmount WFLOW, should get back approximately: + // initialAmount × (1 - fee)² (lost fee on each leg) + // Note: The actual calculation is more complex due to price conversion: + // Forward: WFLOW → PYUSD0 = amount × price × (1 - fee) + // Reverse: PYUSD0 → WFLOW = pyusd / price × (1 - fee) + // Net: amount × (1 - fee)² + let feeMultiplier = 1.0 - (UFix64(univ3PoolFee) / 1_000_000.0) + let expectedWflowReturned = initialAmount * feeMultiplier * feeMultiplier + + // Use larger tolerance for round-trip due to cumulative precision errors + let tolerance = 0.0001 + Test.assert( + equalAmounts(a: wflowReturned, b: expectedWflowReturned, tolerance: tolerance), + message: "Round-trip price=\(price): returned \(wflowReturned) not within \(tolerance) of expected \(expectedWflowReturned)" + ) + + let feesLost = initialAmount - wflowReturned + let feePercentage = (feesLost / initialAmount) * 100.0 + log("Round-trip price=\(price) Step 2: PYUSD0→WFLOW: sent=\(pyusdReceived) returned=\(wflowReturned) expected=\(expectedWflowReturned)") + log("Round-trip price=\(price) Summary: initial=\(initialAmount) final=\(wflowReturned) fees_lost=\(feesLost) (\(feePercentage)%)") + } +} + +/// Test different fee tiers: verify fee adjustment works correctly across 100, 500, 3000 bps +/// This matches the actual fee tiers used in production (100 for stablecoin pairs, 3000 for volatile) +access(all) +fun test_UniswapV3DifferentFeeTiers() { + let feeTiers: [UInt64] = [feeTier100, feeTier500, feeTier3000] + let targetPrice = 1.5 + let amount = 10000.0 + + for fee in feeTiers { + Test.reset(to: snapshot) + + // Set pool with forward fee adjustment for each fee tier + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: fee, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(targetPrice), fee: fee, reverse: false), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + let balanceBefore = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, wflowAddress, pyusd0Address, fee, amount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let balanceAfter = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + let swapOutput = balanceAfter - balanceBefore + let expectedOut = targetPrice * amount + + let tolerance = 0.000001 + Test.assert( + equalAmounts(a: swapOutput, b: expectedOut, tolerance: tolerance), + message: "Fee tier \(fee): swap output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Fee tier \(fee) bps: expected=\(expectedOut) actual=\(swapOutput)") + } +} + +/// Test inverted price with fee adjustment +/// Pattern: priceTokenBPerTokenA: feeAdjustedPrice(1.0 / UFix128(price), fee, reverse: true) +/// Used when token ordering differs from the natural price direction +access(all) +fun test_UniswapV3InvertedPriceWithFeeAdjustment() { + let prices = [0.5, 1.0, 2.0, 3.0, 5.0] + let amount = 10000.0 + + for price in prices { + Test.reset(to: snapshot) + + // Inverted price pattern: 1.0 / price + // This is used when we want to express price in the opposite direction + // e.g., if price = 2.0 (1 WFLOW = 2 PYUSD0), inverted = 0.5 (1 PYUSD0 = 0.5 WFLOW) + let invertedPrice = 1.0 / price + + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(invertedPrice), fee: univ3PoolFee, reverse: true), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + // Swap PYUSD0 → WFLOW + // Use generic swap transaction that dynamically resolves input token vault + let wflowBalanceBefore = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath) ?? 0.0 + + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap_generic.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, pyusd0Address, wflowAddress, univ3PoolFee, amount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let wflowBalanceAfter = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath)! + let swapOutput = wflowBalanceAfter - wflowBalanceBefore + // With inverted price = 1/price, output = amount / invertedPrice = amount × price + let expectedOut = amount * price + + // Use larger tolerance for reverse swaps due to bridge fee variability + let tolerance = 0.001 + Test.assert( + equalAmounts(a: swapOutput, b: expectedOut, tolerance: tolerance), + message: "Inverted price (1/\(price)): swap output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Inverted price (1/\(price) = \(invertedPrice)): expected=\(expectedOut) actual=\(swapOutput)") + } +} + +/// Test dynamic fee direction based on condition (as used in rebalance tests) +/// Pattern: priceTokenBPerTokenA: feeAdjustedPrice(1.0, fee, reverse: price < 1.0) +/// Direction changes based on whether we're in surplus or deficit scenario +access(all) +fun test_UniswapV3DynamicFeeDirection() { + // Prices that trigger different directions + // price < 1.0: reverse=true (deficit scenario) + // price >= 1.0: reverse=false (surplus scenario) + let prices = [0.5, 0.8, 1.0, 1.2, 2.0] + let amount = 5000.0 + + for price in prices { + Test.reset(to: snapshot) + + let isDeficit = price < 1.0 + + // Set pool with dynamic direction based on price + setPoolToPrice( + factoryAddress: factoryAddress, + tokenAAddress: wflowAddress, + tokenBAddress: pyusd0Address, + fee: univ3PoolFee, + priceTokenBPerTokenA: feeAdjustedPrice(UFix128(price), fee: univ3PoolFee, reverse: isDeficit), + tokenABalanceSlot: wflowBalanceSlot, + tokenBBalanceSlot: pyusd0BalanceSlot, + signer: testAccount + ) + + // For forward case (surplus), swap WFLOW → PYUSD0 + // For reverse case (deficit), swap PYUSD0 → WFLOW + if !isDeficit { + // Forward: WFLOW → PYUSD0 + let balanceBefore = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, wflowAddress, pyusd0Address, univ3PoolFee, amount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let balanceAfter = getBalance(address: testAccount.address, vaultPublicPath: pyusd0PublicPath)! + let swapOutput = balanceAfter - balanceBefore + let expectedOut = price * amount + + let tolerance = 0.000001 + Test.assert( + equalAmounts(a: swapOutput, b: expectedOut, tolerance: tolerance), + message: "Dynamic direction (surplus, price=\(price)): output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Dynamic direction FORWARD (surplus, price=\(price)): expected=\(expectedOut) actual=\(swapOutput)") + } else { + // Reverse: PYUSD0 → WFLOW + // Use generic swap transaction that dynamically resolves input token vault + let balanceBefore = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath) ?? 0.0 + + let swapRes = Test.executeTransaction( + Test.Transaction( + code: Test.readFile("transactions/execute_univ3_swap_generic.cdc"), + authorizers: [testAccount.address], + signers: [testAccount], + arguments: [factoryAddress, routerAddress, quoterAddress, pyusd0Address, wflowAddress, univ3PoolFee, amount] + ) + ) + Test.expect(swapRes, Test.beSucceeded()) + + let balanceAfter = getBalance(address: testAccount.address, vaultPublicPath: flowTokenPublicPath)! + let swapOutput = balanceAfter - balanceBefore + let expectedOut = amount / price + + let tolerance = 0.000001 + Test.assert( + equalAmounts(a: swapOutput, b: expectedOut, tolerance: tolerance), + message: "Dynamic direction (deficit, price=\(price)): output \(swapOutput) not within \(tolerance) of expected \(expectedOut)" + ) + log("Dynamic direction REVERSE (deficit, price=\(price)): expected=\(expectedOut) actual=\(swapOutput)") + } + } +}