From 2fc71cf81b83268e95cda86697e04acc39db81e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Uhl=C3=AD=C5=99?= Date: Tue, 24 Oct 2023 12:12:54 +0200 Subject: [PATCH] feat: partial payouts for cancelled requests (#561) --- codex/contracts/requests.nim | 1 + codex/purchasing/states/cancelled.nim | 2 +- codex/purchasing/states/submitted.nim | 2 +- codex/sales/salesagent.nim | 21 +++++++++++++++++-- codex/sales/states/cancelled.nim | 28 ++++++++++++++++++++++---- codex/sales/states/failed.nim | 14 +++++++++++++ codex/sales/states/unknown.nim | 2 ++ tests/codex/sales/testsales.nim | 27 ++++++++++++++++++++++++- tests/codex/testpurchasing.nim | 4 ++-- tests/examples.nim | 7 +++++++ tests/integration/codexclient.nim | 5 +++-- tests/integration/testIntegration.nim | 29 +++++++++++++++++++++++++++ vendor/codex-contracts-eth | 2 +- 13 files changed, 130 insertions(+), 14 deletions(-) diff --git a/codex/contracts/requests.nim b/codex/contracts/requests.nim index 482c5161..5d5a7780 100644 --- a/codex/contracts/requests.nim +++ b/codex/contracts/requests.nim @@ -54,6 +54,7 @@ type Finished Failed Paid + Cancelled proc `==`*(x, y: Nonce): bool {.borrow.} proc `==`*(x, y: RequestId): bool {.borrow.} diff --git a/codex/purchasing/states/cancelled.nim b/codex/purchasing/states/cancelled.nim index aedca207..e3db9844 100644 --- a/codex/purchasing/states/cancelled.nim +++ b/codex/purchasing/states/cancelled.nim @@ -22,4 +22,4 @@ method run*(state: PurchaseCancelled, machine: Machine): Future[?State] {.async. await purchase.market.withdrawFunds(purchase.requestId) let error = newException(Timeout, "Purchase cancelled due to timeout") - return some State(PurchaseErrored(error: error)) + purchase.future.fail(error) diff --git a/codex/purchasing/states/submitted.nim b/codex/purchasing/states/submitted.nim index 53c36864..41869430 100644 --- a/codex/purchasing/states/submitted.nim +++ b/codex/purchasing/states/submitted.nim @@ -33,7 +33,7 @@ method run*(state: PurchaseSubmitted, machine: Machine): Future[?State] {.async. await subscription.unsubscribe() proc withTimeout(future: Future[void]) {.async.} = - let expiry = request.expiry.truncate(int64) + let expiry = request.expiry.truncate(int64) + 1 await future.withTimeout(clock, expiry) try: diff --git a/codex/sales/salesagent.nim b/codex/sales/salesagent.nim index 97cf1387..3db520b4 100644 --- a/codex/sales/salesagent.nim +++ b/codex/sales/salesagent.nim @@ -54,6 +54,11 @@ proc retrieveRequest*(agent: SalesAgent) {.async.} = if data.request.isNone: data.request = await market.getRequest(data.requestId) +proc retrieveRequestState*(agent: SalesAgent): Future[?RequestState] {.async.} = + let data = agent.data + let market = agent.context.market + return await market.requestState(data.requestId) + proc subscribeCancellation(agent: SalesAgent) {.async.} = let data = agent.data let clock = agent.context.clock @@ -62,8 +67,20 @@ proc subscribeCancellation(agent: SalesAgent) {.async.} = without request =? data.request: return - await clock.waitUntil(request.expiry.truncate(int64)) - agent.schedule(cancelledEvent(request)) + while true: + let deadline = max(clock.now, request.expiry.truncate(int64)) + 1 + trace "Waiting for request to be cancelled", now=clock.now, expiry=deadline + await clock.waitUntil(deadline) + + without state =? await agent.retrieveRequestState(): + error "Uknown request", requestId = data.requestId + return + + if state == RequestState.Cancelled: + agent.schedule(cancelledEvent(request)) + break + + debug "The request is not yet canceled, even though it should be. Waiting for some more time.", currentState = state, now=clock.now data.cancelled = onCancelled() diff --git a/codex/sales/states/cancelled.nim b/codex/sales/states/cancelled.nim index 0e7e5259..54b8e553 100644 --- a/codex/sales/states/cancelled.nim +++ b/codex/sales/states/cancelled.nim @@ -1,4 +1,5 @@ import pkg/chronicles +import ../salesagent import ../statemachine import ./errorhandling import ./errored @@ -8,11 +9,30 @@ logScope: type SaleCancelled* = ref object of ErrorHandlingState - SaleCancelledError* = object of CatchableError - SaleTimeoutError* = object of SaleCancelledError method `$`*(state: SaleCancelled): string = "SaleCancelled" method run*(state: SaleCancelled, machine: Machine): Future[?State] {.async.} = - let error = newException(SaleTimeoutError, "Sale cancelled due to timeout") - return some State(SaleErrored(error: error)) + let agent = SalesAgent(machine) + let data = agent.data + let market = agent.context.market + + without request =? data.request: + raiseAssert "no sale request" + + without slotIndex =? data.slotIndex: + raiseAssert("no slot index assigned") + + let slot = Slot(request: request, slotIndex: slotIndex) + debug "Collecting collateral and partial payout", requestId = $data.requestId, slotIndex + await market.freeSlot(slot.id) + + if onClear =? agent.context.onClear and + request =? data.request and + slotIndex =? data.slotIndex: + onClear(request, slotIndex) + + if onCleanUp =? agent.onCleanUp: + await onCleanUp() + + warn "Sale cancelled due to timeout", requestId = $data.requestId, slotIndex diff --git a/codex/sales/states/failed.nim b/codex/sales/states/failed.nim index aced9c56..461beb13 100644 --- a/codex/sales/states/failed.nim +++ b/codex/sales/states/failed.nim @@ -1,4 +1,5 @@ import pkg/chronicles +import ../salesagent import ../statemachine import ./errorhandling import ./errored @@ -13,5 +14,18 @@ type method `$`*(state: SaleFailed): string = "SaleFailed" method run*(state: SaleFailed, machine: Machine): Future[?State] {.async.} = + let data = SalesAgent(machine).data + let market = SalesAgent(machine).context.market + + without request =? data.request: + raiseAssert "no sale request" + + without slotIndex =? data.slotIndex: + raiseAssert("no slot index assigned") + + let slot = Slot(request: request, slotIndex: slotIndex) + debug "Removing slot from mySlots", requestId = $data.requestId, slotIndex + await market.freeSlot(slot.id) + let error = newException(SaleFailedError, "Sale failed") return some State(SaleErrored(error: error)) diff --git a/codex/sales/states/unknown.nim b/codex/sales/states/unknown.nim index ed0a96b3..0cd5684d 100644 --- a/codex/sales/states/unknown.nim +++ b/codex/sales/states/unknown.nim @@ -54,3 +54,5 @@ method run*(state: SaleUnknown, machine: Machine): Future[?State] {.async.} = return some State(SaleFinished()) of SlotState.Failed: return some State(SaleFailed()) + of SlotState.Cancelled: + return some State(SaleCancelled()) diff --git a/tests/codex/sales/testsales.nim b/tests/codex/sales/testsales.nim index dd7a6345..9827a357 100644 --- a/tests/codex/sales/testsales.nim +++ b/tests/codex/sales/testsales.nim @@ -453,10 +453,35 @@ asyncchecksuite "Sales": return success() createAvailability() await market.requestStorage(request) - clock.set(request.expiry.truncate(int64)) + + # If we would not await, then the `clock.set` would run "too fast" as the `subscribeCancellation()` + # would otherwise not set the timeout early enough as it uses `clock.now` in the deadline calculation. + await sleepAsync(chronos.milliseconds(100)) + market.requestState[request.id]=RequestState.Cancelled + clock.set(request.expiry.truncate(int64)+1) check eventually (await reservations.all(Availability)).get == @[availability] check getAvailability().size == origSize + test "verifies that request is indeed expired from onchain before firing onCancelled": + let origSize = availability.size + sales.onStore = proc(request: StorageRequest, + slot: UInt256, + onBatch: BatchProc): Future[?!void] {.async.} = + await sleepAsync(chronos.hours(1)) + return success() + createAvailability() + await market.requestStorage(request) + market.requestState[request.id]=RequestState.New # "On-chain" is the request still ongoing even after local expiration + + # If we would not await, then the `clock.set` would run "too fast" as the `subscribeCancellation()` + # would otherwise not set the timeout early enough as it uses `clock.now` in the deadline calculation. + await sleepAsync(chronos.milliseconds(100)) + clock.set(request.expiry.truncate(int64)+1) + check getAvailability().size == 0 + + market.requestState[request.id]=RequestState.Cancelled # Now "on-chain" is also expired + check eventually getAvailability().size == origSize + test "loads active slots from market": let me = await market.getSigner() diff --git a/tests/codex/testpurchasing.nim b/tests/codex/testpurchasing.nim index 719b0326..3efe92c4 100644 --- a/tests/codex/testpurchasing.nim +++ b/tests/codex/testpurchasing.nim @@ -105,7 +105,7 @@ asyncchecksuite "Purchasing": let purchase = await purchasing.purchase(request) check eventually market.requested.len > 0 let request = market.requested[0] - clock.set(request.expiry.truncate(int64)) + clock.set(request.expiry.truncate(int64) + 1) expect PurchaseTimeout: await purchase.wait() @@ -113,7 +113,7 @@ asyncchecksuite "Purchasing": let purchase = await purchasing.purchase(request) check eventually market.requested.len > 0 let request = market.requested[0] - clock.set(request.expiry.truncate(int64)) + clock.set(request.expiry.truncate(int64) + 1) expect PurchaseTimeout: await purchase.wait() check market.withdrawn == @[request.id] diff --git a/tests/examples.nim b/tests/examples.nim index a3171f27..1f2a4466 100644 --- a/tests/examples.nim +++ b/tests/examples.nim @@ -1,4 +1,5 @@ import std/random +import std/strutils import std/sequtils import std/times import std/typetraits @@ -6,6 +7,12 @@ import pkg/codex/contracts/requests import pkg/codex/sales/slotqueue import pkg/stint +proc exampleString*(length: int): string = + let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result = newString(length) # Create a new empty string with a given length + for i in 0.. 0 and ((await token.balanceOf(account2)) - startBalanceClient2) < 10*reward + check eventually (startBalanceClient1 - (await token.balanceOf(account1))) == ((await token.balanceOf(account2)) - startBalanceClient2) diff --git a/vendor/codex-contracts-eth b/vendor/codex-contracts-eth index 1854dfba..14e453ac 160000 --- a/vendor/codex-contracts-eth +++ b/vendor/codex-contracts-eth @@ -1 +1 @@ -Subproject commit 1854dfba9991a25532de5f6a53cf50e66afb3c8b +Subproject commit 14e453ac3150e6c9ca277e605d5df9389ac7eea7