Skip to content

Commit 26ebbe9

Browse files
committed
refactor: Address PR comments (Oracle abstraction, naming, safe redemption)
1 parent 20da658 commit 26ebbe9

6 files changed

Lines changed: 113 additions & 25 deletions

File tree

File renamed without changes.

cadence/contracts/RedemptionWrapper.cdc

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ access(all) contract RedemptionWrapper {
2323
moetBurned: UFix64,
2424
collateralType: String,
2525
collateralReceived: UFix64,
26-
oraclePrice: UFix64,
26+
collateralOraclePrice: UFix64,
2727
preRedemptionHealth: UFix128,
2828
postRedemptionHealth: UFix128
2929
)
@@ -42,7 +42,7 @@ access(all) contract RedemptionWrapper {
4242
access(all) var minRedemptionAmount: UFix64
4343

4444
// Rate limiting
45-
access(all) var redemptionCooldown: UFix64
45+
access(all) var redemptionCooldownSeconds: UFix64
4646
access(all) var dailyRedemptionLimit: UFix64
4747
access(all) var dailyRedemptionUsed: UFix64
4848
access(all) var lastRedemptionResetDay: UFix64
@@ -51,6 +51,7 @@ access(all) contract RedemptionWrapper {
5151
// Oracle and health protections
5252
access(all) var maxPriceAge: UFix64
5353
access(all) var minPostRedemptionHealth: UFix128
54+
access(all) var oracle: {DeFiActions.PriceOracle}
5455

5556
// Position tracking
5657
access(all) var positionID: UInt64?
@@ -77,22 +78,26 @@ access(all) contract RedemptionWrapper {
7778
}
7879

7980
access(all) fun setProtectionParams(
80-
redemptionCooldown: UFix64,
81+
redemptionCooldownSeconds: UFix64,
8182
dailyRedemptionLimit: UFix64,
8283
maxPriceAge: UFix64,
8384
minPostRedemptionHealth: UFix128
8485
) {
8586
pre {
86-
redemptionCooldown <= 3600.0: "Cooldown too long (max 1 hour)"
87+
redemptionCooldownSeconds <= 3600.0: "Cooldown too long (max 1 hour)"
8788
dailyRedemptionLimit > 0.0: "Daily limit must be positive"
8889
maxPriceAge <= 7200.0: "Max price age too long (max 2 hours)"
8990
minPostRedemptionHealth >= FlowALPMath.toUFix128(1.0): "Min post-redemption health must be >= 1.0"
9091
}
91-
RedemptionWrapper.redemptionCooldown = redemptionCooldown
92+
RedemptionWrapper.redemptionCooldownSeconds = redemptionCooldownSeconds
9293
RedemptionWrapper.dailyRedemptionLimit = dailyRedemptionLimit
9394
RedemptionWrapper.maxPriceAge = maxPriceAge
9495
RedemptionWrapper.minPostRedemptionHealth = minPostRedemptionHealth
9596
}
97+
98+
access(all) fun setOracle(_ newOracle: {DeFiActions.PriceOracle}) {
99+
RedemptionWrapper.oracle = newOracle
100+
}
96101

97102
access(all) fun pause() {
98103
RedemptionWrapper.paused = true
@@ -123,7 +128,7 @@ access(all) contract RedemptionWrapper {
123128
moet: @MOET.Vault,
124129
preferredCollateralType: Type?,
125130
receiver: Capability<&{FungibleToken.Receiver}>
126-
) {
131+
): @MOET.Vault? {
127132
pre {
128133
!RedemptionWrapper.reentrancyGuard: "Reentrancy detected"
129134
!RedemptionWrapper.paused: "Redemptions are paused"
@@ -146,7 +151,7 @@ access(all) contract RedemptionWrapper {
146151
let userAddr = receiver.address
147152
if let lastTime = RedemptionWrapper.userLastRedemption[userAddr] {
148153
assert(
149-
getCurrentBlock().timestamp - lastTime >= RedemptionWrapper.redemptionCooldown,
154+
getCurrentBlock().timestamp - lastTime >= RedemptionWrapper.redemptionCooldownSeconds,
150155
message: "Redemption cooldown not elapsed"
151156
)
152157
}
@@ -179,23 +184,20 @@ access(all) contract RedemptionWrapper {
179184
let sink = position.createSink(type: Type<@MOET.Vault>())
180185
sink.depositCapacity(from: &moet as auth(FungibleToken.Withdraw) &{FungibleToken.Vault})
181186
let repaid = moetAmount - moet.balance
182-
destroy moet
183-
184-
// Validate MOET was burned
185-
assert(repaid > 0.0, message: "No MOET was repaid/burned")
187+
188+
// Validate MOET was repaid
189+
assert(repaid > 0.0, message: "No MOET was repaid")
186190

187191
// Determine collateral type (default to FlowToken if not specified)
188192
let collateralType = preferredCollateralType ?? Type<@FlowToken.Vault>()
189193

190194
// Get oracle price for the collateral
191-
// Create a PriceOracle struct instance to access prices
192-
let oracle = MockOracle.PriceOracle()
193-
let collateralPrice = oracle.price(ofToken: collateralType)
195+
let collateralPriceUSD = RedemptionWrapper.oracle.price(ofToken: collateralType)
194196
?? panic("Oracle price unavailable for collateral type")
195197

196198
// Calculate exact collateral amount for 1:1 USD parity
197-
// MOET is pegged to $1, so: collateralAmount = moetAmount / collateralPriceUSD
198-
let collateralAmount = repaid / collateralPrice
199+
// MOET is pegged to $1, so: collateralAmount = repaid / collateralPriceUSD
200+
let collateralAmount = repaid / collateralPriceUSD
199201

200202
// Validate sufficient collateral is available
201203
let available = position.availableBalance(type: collateralType, pullFromTopUpSource: false)
@@ -236,27 +238,34 @@ access(all) contract RedemptionWrapper {
236238
moetBurned: repaid,
237239
collateralType: collateralType.identifier,
238240
collateralReceived: actualWithdrawn,
239-
oraclePrice: collateralPrice,
241+
collateralOraclePrice: collateralPriceUSD,
240242
preRedemptionHealth: preHealth,
241243
postRedemptionHealth: postHealth
242244
)
245+
246+
if moet.balance > 0.0 {
247+
return <-moet
248+
}
249+
destroy moet
250+
return nil
243251
}
244252
}
245253

246254
/// Setup the redemption position with initial collateral
247255
/// This must be called once before any redemptions can occur
248256
/// If called multiple times (e.g., in tests), it will overwrite the previous position
249257
access(all) fun setup(
258+
admin: &Admin,
250259
initialCollateral: @{FungibleToken.Vault},
251260
issuanceSink: {DeFiActions.Sink},
252261
repaymentSource: {DeFiActions.Source}?
253262
) {
254263
// Allow re-setup for testing - clean up previous position if exists
255264
if self.positionID != nil {
256265
// Remove old position (structs don't need destroying)
257-
self.account.storage.load<FlowALP.Position>(from: self.RedemptionPositionStoragePath)
266+
let _ = self.account.storage.load<FlowALP.Position>(from: self.RedemptionPositionStoragePath)
258267
// Remove old pool cap (capabilities don't need destroying)
259-
self.account.storage.load<Capability<auth(FlowALP.EParticipant, FlowALP.EPosition) &FlowALP.Pool>>(from: self.PoolCapStoragePath)
268+
let unusedCap = self.account.storage.load<Capability<auth(FlowALP.EParticipant, FlowALP.EPosition) &FlowALP.Pool>>(from: self.PoolCapStoragePath)
260269
}
261270

262271
let poolCap = self.account.storage.load<Capability<auth(FlowALP.EParticipant, FlowALP.EPosition) &FlowALP.Pool>>(
@@ -318,7 +327,7 @@ access(all) contract RedemptionWrapper {
318327
self.minRedemptionAmount = 10.0 // Prevent spam
319328

320329
// Rate limiting for MEV protection
321-
self.redemptionCooldown = 60.0 // 1 minute cooldown per user
330+
self.redemptionCooldownSeconds = 60.0 // 1 minute cooldown per user
322331
self.dailyRedemptionLimit = 100000.0 // 100k MOET per day circuit breaker
323332
self.dailyRedemptionUsed = 0.0
324333
self.lastRedemptionResetDay = UFix64(getCurrentBlock().timestamp) / 86400.0
@@ -327,6 +336,7 @@ access(all) contract RedemptionWrapper {
327336
// Oracle and health protections
328337
self.maxPriceAge = 3600.0 // 1 hour max price age
329338
self.minPostRedemptionHealth = FlowALPMath.toUFix128(1.15) // Require 115% health after redemption
339+
self.oracle = MockOracle.PriceOracle() // Default to MockOracle
330340

331341
// Position tracking
332342
self.positionID = nil

cadence/tests/redemption_wrapper_test.cdc

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,9 @@ fun test_user_cooldown_enforcement() {
216216

217217
// NOTE: Cannot test cooldown expiration without BlockchainHelpers.commitBlock()
218218
// The cooldown enforcement is validated above - the expiration would require
219-
// time advancement which isn't available in the current test framework
219+
// time advancement which isn't available in the current test framework.
220+
// Attempted to use empty transactions to advance blocks, but Test.executeTransaction
221+
// does not advance block height or timestamp in this environment.
220222
}
221223

222224
/// Test 5: Min/Max Redemption Amounts
@@ -353,7 +355,7 @@ fun test_sequential_redemptions() {
353355
log("User ".concat(i.toString()).concat(" redeemed. Position health: ").concat(health.toString()))
354356

355357
// Health should remain above minimum (1.15 = 115%)
356-
Test.assert(health >= 1.15 as UFix128, message: "Position health below minimum after redemption")
358+
Test.assert(health >= 1.15, message: "Position health below minimum after redemption")
357359

358360
i = i + 1
359361
}
@@ -401,6 +403,69 @@ fun test_liquidation_prevention() {
401403
}
402404
}
403405

406+
/// Test 11: Excess MOET Handling
407+
/// Verifies that if user sends more MOET than debt, the system handles it (accepts surplus)
408+
access(all)
409+
fun test_excess_moet_return() {
410+
safeReset()
411+
412+
// Ensure price is 2.0
413+
// Use protocolAccount as signer to match other tests, just in case
414+
setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: 2.0)
415+
416+
// Verify price
417+
let price = _executeScript("../scripts/mocks/oracle/get_price.cdc", [flowTokenIdentifier]).returnValue! as! UFix64
418+
log("Oracle Price: ".concat(price.toString()))
419+
Test.assertEqual(2.0, price)
420+
421+
// Setup redemption wrapper with initial collateral
422+
setupMoetVault(protocolAccount, beFailed: false)
423+
giveFlowTokens(to: protocolAccount, amount: 1000.0)
424+
425+
// Setup redemption position
426+
let setupRes = setupRedemptionPosition(signer: protocolAccount, flowAmount: 500.0)
427+
Test.expect(setupRes, Test.beSucceeded())
428+
429+
// Get initial debt
430+
let initialRes = _executeScript("./scripts/redemption/get_position_details.cdc", [])
431+
Test.expect(initialRes, Test.beSucceeded())
432+
let initialState = initialRes.returnValue! as! {String: UFix64}
433+
let initialDebt = initialState["moetDebt"]!
434+
log("Initial Debt: ".concat(initialDebt.toString()))
435+
436+
// Ensure we have some debt
437+
if initialDebt == 0.0 {
438+
Test.assert(initialDebt > 0.0, message: "Initial debt must be > 0 for this test")
439+
}
440+
441+
let user = Test.createAccount()
442+
setupMoetVault(user, beFailed: false)
443+
444+
// Mint MORE than debt
445+
let mintAmount = initialDebt + 50.0
446+
mintMoet(signer: flowALPAccount, to: user.address, amount: mintAmount, beFailed: false)
447+
448+
// Execute redemption with ALL minted MOET
449+
let redeemRes = redeemMoet(user: user, amount: mintAmount)
450+
Test.expect(redeemRes, Test.beSucceeded())
451+
452+
// Verify user received correct Flow for FULL amount (since sink accepts surplus)
453+
// Price is 2.0
454+
let userFlowBalance = getBalance(address: user.address, vaultPublicPath: /public/flowTokenBalance) ?? 0.0
455+
let expectedFlow = mintAmount / 2.0
456+
457+
log("User Flow Balance: ".concat(userFlowBalance.toString()))
458+
log("Expected Flow: ".concat(expectedFlow.toString()))
459+
460+
Test.assert(equalAmounts(a: expectedFlow, b: userFlowBalance, tolerance: 0.00000001), message: "User flow balance mismatch")
461+
462+
// Verify user used all MOET (none returned as sink accepted all)
463+
let userMoetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0
464+
Test.assertEqual(0.0, userMoetBalance)
465+
466+
log("User MOET balance: ".concat(userMoetBalance.toString()))
467+
}
468+
404469
/* --- Helper Functions --- */
405470

406471
access(all)
@@ -449,4 +514,3 @@ fun giveFlowTokens(to: Test.TestAccount, amount: UFix64) {
449514
// Use the test_helpers function to transfer Flow tokens
450515
transferFlowTokens(to: to, amount: amount)
451516
}
452-

cadence/tests/transactions/redemption/configure_protections.cdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ transaction(cooldown: UFix64, dailyLimit: UFix64, maxPriceAge: UFix64, minHealth
88
) ?? panic("No admin resource")
99

1010
adminRef.setProtectionParams(
11-
redemptionCooldown: cooldown,
11+
redemptionCooldownSeconds: cooldown,
1212
dailyRedemptionLimit: dailyLimit,
1313
maxPriceAge: maxPriceAge,
1414
minPostRedemptionHealth: FlowALPMath.toUFix128(minHealth)

cadence/tests/transactions/redemption/redeem_moet.cdc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,19 @@ transaction(moetAmount: UFix64) {
2020
?? panic("No redeemer capability")
2121

2222
// Execute redemption (uses default collateral type)
23-
redeemer.redeem(
23+
let change <- redeemer.redeem(
2424
moet: <-moetVault,
2525
preferredCollateralType: nil,
2626
receiver: flowReceiver
2727
)
28+
29+
if change != nil {
30+
// Deposit change back to user's MOET vault
31+
signer.storage.borrow<&MOET.Vault>(from: MOET.VaultStoragePath)!
32+
.deposit(from: <-change!)
33+
} else {
34+
destroy change
35+
}
2836
}
2937
}
3038

cadence/tests/transactions/redemption/setup_redemption_position.cdc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@ transaction(flowAmount: UFix64) {
2222
uniqueID: nil
2323
)
2424

25+
// Borrow Admin resource
26+
let adminRef = signer.storage.borrow<&RedemptionWrapper.Admin>(
27+
from: RedemptionWrapper.AdminStoragePath
28+
) ?? panic("No admin resource - setup requires Admin authorization")
29+
2530
// Setup redemption position (no repayment source for testing simplicity)
2631
RedemptionWrapper.setup(
32+
admin: adminRef,
2733
initialCollateral: <-flowVault,
2834
issuanceSink: issuanceSink,
2935
repaymentSource: nil

0 commit comments

Comments
 (0)