feat: partial payouts for cancelled requests (#561)

This commit is contained in:
Adam Uhlíř 2023-10-24 12:12:54 +02:00 committed by GitHub
parent 8a7d74e6b2
commit 2fc71cf81b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 130 additions and 14 deletions

View File

@ -54,6 +54,7 @@ type
Finished
Failed
Paid
Cancelled
proc `==`*(x, y: Nonce): bool {.borrow.}
proc `==`*(x, y: RequestId): bool {.borrow.}

View File

@ -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)

View File

@ -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:

View File

@ -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()

View File

@ -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

View File

@ -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))

View File

@ -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())

View File

@ -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()

View File

@ -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]

View File

@ -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..<length:
result[i] = chars[rand(chars.len-1)] # Generate a random index and set the string's character
proc example*[T: SomeInteger](_: type T): T =
rand(T)

View File

@ -64,10 +64,11 @@ proc getPurchase*(client: CodexClient, purchaseId: PurchaseId): ?!RestPurchase =
let json = ? parseJson(body).catch
RestPurchase.fromJson(json)
proc getSlots*(client: CodexClient): JsonNode =
proc getSlots*(client: CodexClient): ?!seq[Slot] =
let url = client.baseurl & "/sales/slots"
let body = client.http.getContent(url)
parseJson(body).catch |? nil
let json = ? parseJson(body).catch
seq[Slot].fromJson(json)
proc postAvailability*(
client: CodexClient,

View File

@ -1,4 +1,5 @@
import std/options
import std/sequtils
from pkg/libp2p import `==`
import pkg/chronos
import pkg/stint
@ -10,6 +11,7 @@ import pkg/codex/utils/stintutils
import ../contracts/time
import ../contracts/deployment
import ../codex/helpers
import ../examples
import ./twonodes
@ -164,3 +166,30 @@ twonodessuite "Integration tests", debug1 = false, debug2 = false:
await provider.advanceTime(duration)
check eventually (await token.balanceOf(account2)) - startBalance == duration*reward
test "expired request partially pays out for stored time":
let marketplace = Marketplace.new(Marketplace.address, provider.getSigner())
let tokenAddress = await marketplace.token()
let token = Erc20Token.new(tokenAddress, provider.getSigner())
let reward = 400.u256
let duration = 100.u256
# client 2 makes storage available
let startBalanceClient2 = await token.balanceOf(account2)
discard client2.postAvailability(size=140000.u256, duration=200.u256, minPrice=300.u256, maxCollateral=300.u256).get
# client 1 requests storage but requires two nodes to host the content
let startBalanceClient1 = await token.balanceOf(account1)
let expiry = (await provider.currentTime()) + 10
let cid = client1.upload(exampleString(100000)).get
let id = client1.requestStorage(cid, duration=duration, reward=reward, proofProbability=3.u256, expiry=expiry, collateral=200.u256, nodes=2).get
# We have to wait for Client 2 fills the slot, before advancing time.
# Until https://github.com/codex-storage/nim-codex/issues/594 is implemented nothing better then
# sleeping some seconds is available.
await sleepAsync(2.seconds)
await provider.advanceTimeTo(expiry+1)
check eventually(client1.purchaseStateIs(id, "cancelled"), 20000)
check eventually ((await token.balanceOf(account2)) - startBalanceClient2) > 0 and ((await token.balanceOf(account2)) - startBalanceClient2) < 10*reward
check eventually (startBalanceClient1 - (await token.balanceOf(account1))) == ((await token.balanceOf(account2)) - startBalanceClient2)

@ -1 +1 @@
Subproject commit 1854dfba9991a25532de5f6a53cf50e66afb3c8b
Subproject commit 14e453ac3150e6c9ca277e605d5df9389ac7eea7