[marketplace] sales state machine tests

This commit is contained in:
Eric Mastro 2022-11-10 19:45:46 +11:00
parent fb96a9eb17
commit a3f9e54cc1
No known key found for this signature in database
GPG Key ID: 141E3048D95A4E63
6 changed files with 354 additions and 17 deletions

View File

@ -69,7 +69,7 @@ method getHost(market: OnChainMarket,
else: else:
return none Address return none Address
method getSlot(market: OnChainMarket, slotId: SlotId): Future[?Slot] {.async.} = method getSlot*(market: OnChainMarket, slotId: SlotId): Future[?Slot] {.async.} =
try: try:
return some await market.contract.getSlot(slotId) return some await market.contract.getSlot(slotId)
except ProviderError as e: except ProviderError as e:

View File

@ -39,12 +39,10 @@ type
Cancelled Cancelled
Finished Finished
Failed Failed
Slot* = object Slot* = object of RootObj
host*: Address host*: Address
hostPaid*: bool hostPaid*: bool
requestId*: RequestId requestId*: RequestId
slotIndex*: UInt256
proof*: seq[byte]
proc `==`*(x, y: Nonce): bool {.borrow.} proc `==`*(x, y: Nonce): bool {.borrow.}
proc `==`*(x, y: RequestId): bool {.borrow.} proc `==`*(x, y: RequestId): bool {.borrow.}
@ -117,6 +115,9 @@ func solidityType*(_: type StorageAsk): string =
func solidityType*(_: type StorageRequest): string = func solidityType*(_: type StorageRequest): string =
solidityType(StorageRequest.fieldTypes) solidityType(StorageRequest.fieldTypes)
func solidityType*(_: type Slot): string =
solidityType(Slot.fieldTypes)
func solidityType*[T: RequestId | SlotId | Nonce](_: type T): string = func solidityType*[T: RequestId | SlotId | Nonce](_: type T): string =
solidityType(array[32, byte]) solidityType(array[32, byte])

View File

@ -111,7 +111,6 @@ method exit(state: AsyncState) =
proc switchAsync*(machine: StateMachineAsync, newState: AsyncState) {.async.} = proc switchAsync*(machine: StateMachineAsync, newState: AsyncState) {.async.} =
if state =? (machine.state as AsyncState): if state =? (machine.state as AsyncState):
trace "Switching sales state", `from`=state, to=newState trace "Switching sales state", `from`=state, to=newState
debugEcho "switching from ", state, " to ", newState
if activeTransition =? state.activeTransition and if activeTransition =? state.activeTransition and
not activeTransition.completed: not activeTransition.completed:
await activeTransition.cancelAndWait() await activeTransition.cancelAndWait()
@ -121,7 +120,6 @@ proc switchAsync*(machine: StateMachineAsync, newState: AsyncState) {.async.} =
state.context = none StateMachine state.context = none StateMachine
else: else:
trace "Switching sales state", `from`="no state", to=newState trace "Switching sales state", `from`="no state", to=newState
debugEcho "switching from no state to ", newState
machine.state = some State(newState) machine.state = some State(newState)
newState.context = some StateMachine(machine) newState.context = some StateMachine(machine)

View File

@ -2,6 +2,7 @@ import std/sequtils
import std/tables import std/tables
import std/hashes import std/hashes
import pkg/codex/market import pkg/codex/market
import pkg/codex/contracts/requests
export market export market
export tables export tables
@ -14,7 +15,7 @@ type
requestEnds*: Table[RequestId, SecondsSince1970] requestEnds*: Table[RequestId, SecondsSince1970]
state*: Table[RequestId, RequestState] state*: Table[RequestId, RequestState]
fulfilled*: seq[Fulfillment] fulfilled*: seq[Fulfillment]
filled*: seq[Slot] filled*: seq[MockSlot]
withdrawn*: seq[RequestId] withdrawn*: seq[RequestId]
signer: Address signer: Address
subscriptions: Subscriptions subscriptions: Subscriptions
@ -22,6 +23,9 @@ type
requestId*: RequestId requestId*: RequestId
proof*: seq[byte] proof*: seq[byte]
host*: Address host*: Address
MockSlot* = object of Slot
slotIndex*: UInt256
proof*: seq[byte]
Subscriptions = object Subscriptions = object
onRequest: seq[RequestSubscription] onRequest: seq[RequestSubscription]
onFulfillment: seq[FulfillmentSubscription] onFulfillment: seq[FulfillmentSubscription]
@ -83,6 +87,14 @@ method getRequest(market: MockMarket,
return some request return some request
return none StorageRequest return none StorageRequest
method getSlot*(market: MockMarket,
slotId: SlotId): Future[?Slot] {.async.} =
for slot in market.filled:
if slotId(slot.requestId, slot.slotIndex) == slotId:
return some Slot(host: slot.host,
requestId: slot.requestId)
return none Slot
method getState*(market: MockMarket, method getState*(market: MockMarket,
requestId: RequestId): Future[?RequestState] {.async.} = requestId: RequestId): Future[?RequestState] {.async.} =
return market.state.?[requestId] return market.state.?[requestId]
@ -132,7 +144,7 @@ proc fillSlot*(market: MockMarket,
slotIndex: UInt256, slotIndex: UInt256,
proof: seq[byte], proof: seq[byte],
host: Address) = host: Address) =
let slot = Slot( let slot = MockSlot(
requestId: requestId, requestId: requestId,
slotIndex: slotIndex, slotIndex: slotIndex,
proof: proof, proof: proof,

View File

@ -1,9 +1,13 @@
import std/sets import std/sets
import std/sequtils
import std/sugar
import std/times import std/times
import pkg/asynctest import pkg/asynctest
import pkg/chronos import pkg/chronos
import pkg/codex/contracts/requests # import pkg/codex/contracts/requests
import pkg/codex/sales import pkg/codex/sales
import pkg/codex/sales/states/[downloading, cancelled, errored, filled, filling,
failed, proving, finished, unknown]
import ./helpers/mockmarket import ./helpers/mockmarket
import ./helpers/mockclock import ./helpers/mockclock
import ./examples import ./examples
@ -214,3 +218,301 @@ suite "Sales":
discard await market.requestStorage(request) discard await market.requestStorage(request)
check proving.slots.len == 1 check proving.slots.len == 1
check proving.slots.contains(request.slotId(soldSlotIndex)) check proving.slots.contains(request.slotId(soldSlotIndex))
suite "Sales state machine":
let availability = Availability.init(
size=100.u256,
duration=60.u256,
minPrice=600.u256
)
var request = StorageRequest(
ask: StorageAsk(
slots: 4,
slotSize: 100.u256,
duration: 60.u256,
reward: 10.u256,
),
content: StorageContent(
cid: "some cid"
)
)
let proof = exampleProof()
var sales: Sales
var market: MockMarket
var clock: MockClock
var proving: Proving
setup:
market = MockMarket.new()
clock = MockClock.new()
proving = Proving.new()
sales = Sales.new(market, clock, proving)
sales.onStore = proc(request: StorageRequest,
slot: UInt256,
availability: ?Availability) {.async.} =
discard
sales.onProve = proc(request: StorageRequest,
slot: UInt256): Future[seq[byte]] {.async.} =
return proof
await sales.start()
request.expiry = (clock.now() + 42).u256
teardown:
await sales.stop()
proc newSalesAgent(slotIdx: UInt256 = 0.u256): SalesAgent =
let agent = sales.newSalesAgent(request.id,
some availability,
some request)
agent.slotIndex = some slotIdx
return agent
proc fillSlot(slotIdx: UInt256 = 0.u256) {.async.} =
let address = await market.getSigner()
let slot = MockSlot(requestId: request.id,
slotIndex: slotIdx,
proof: proof,
host: address)
market.filled.add slot
test "moves to SaleErrored when SaleFilled errors":
let agent = newSalesAgent()
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleUnknown())
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale host mismatch"
test "moves to SaleFinished when request state is New":
let agent = newSalesAgent()
await fillSlot()
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleUnknown())
check (agent.state as SaleFinished).isSome
test "moves to SaleFinished when request state is Started":
let agent = newSalesAgent()
await fillSlot()
market.state[request.id] = RequestState.Started
agent.switch(SaleUnknown())
check (agent.state as SaleFinished).isSome
test "moves to SaleFinished when request state is Finished":
let agent = newSalesAgent()
market.state[request.id] = RequestState.Finished
agent.switch(SaleUnknown())
check (agent.state as SaleFinished).isSome
test "moves to SaleErrored when request state is Cancelled":
let agent = newSalesAgent()
market.state[request.id] = RequestState.Cancelled
agent.switch(SaleUnknown())
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale cancelled due to timeout"
test "moves to SaleErrored when request state is Failed":
let agent = newSalesAgent()
market.state[request.id] = RequestState.Failed
agent.switch(SaleUnknown())
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale failed"
test "moves to SaleErrored when Downloading and request expires":
sales.onStore = proc(request: StorageRequest,
slot: UInt256,
availability: ?Availability) {.async.} =
await sleepAsync(chronos.minutes(1)) # "far" in the future
request.expiry = (getTime() + initDuration(seconds=2)).toUnix.u256
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleDownloading())
clock.set(request.expiry.truncate(int64))
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale cancelled due to timeout"
test "moves to SaleErrored when Downloading and request fails":
sales.onStore = proc(request: StorageRequest,
slot: UInt256,
availability: ?Availability) {.async.} =
await sleepAsync(chronos.minutes(1)) # "far" in the future
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleDownloading())
market.emitRequestFailed(request.id)
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale failed"
test "moves to SaleErrored when Filling and request expires":
request.expiry = (getTime() + initDuration(seconds=2)).toUnix.u256
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleFilling())
clock.set(request.expiry.truncate(int64))
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale cancelled due to timeout"
test "moves to SaleErrored when Filling and request fails":
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleFilling())
market.emitRequestFailed(request.id)
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale failed"
test "moves to SaleErrored when Finished and request expires":
request.expiry = (getTime() + initDuration(seconds=2)).toUnix.u256
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.Finished
await agent.switchAsync(SaleFinished())
clock.set(request.expiry.truncate(int64))
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale cancelled due to timeout"
test "moves to SaleErrored when Finished and request fails":
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.Finished
await agent.switchAsync(SaleFinished())
market.emitRequestFailed(request.id)
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale failed"
test "moves to SaleErrored when Proving and request expires":
sales.onProve = proc(request: StorageRequest,
slot: UInt256): Future[seq[byte]] {.async.} =
await sleepAsync(chronos.minutes(1)) # "far" in the future
return @[]
request.expiry = (getTime() + initDuration(seconds=2)).toUnix.u256
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleProving())
clock.set(request.expiry.truncate(int64))
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale cancelled due to timeout"
test "moves to SaleErrored when Proving and request fails":
sales.onProve = proc(request: StorageRequest,
slot: UInt256): Future[seq[byte]] {.async.} =
await sleepAsync(chronos.minutes(1)) # "far" in the future
return @[]
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await agent.switchAsync(SaleProving())
market.emitRequestFailed(request.id)
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleErrored)
check state.isSome
check (!state).error.msg == "Sale failed"
test "moves from SaleDownloading to SaleFinished, calling necessary callbacks":
var onProveCalled, onStoreCalled, onClearCalled, onSaleCalled: bool
sales.onProve = proc(request: StorageRequest,
slot: UInt256): Future[seq[byte]] {.async.} =
onProveCalled = true
return @[]
sales.onStore = proc(request: StorageRequest,
slot: UInt256,
availability: ?Availability) {.async.} =
onStoreCalled = true
sales.onClear = proc(availability: ?Availability,
request: StorageRequest,
slotIndex: UInt256) =
onClearCalled = true
sales.onSale = proc(availability: ?Availability,
request: StorageRequest,
slotIndex: UInt256) =
onSaleCalled = true
let agent = newSalesAgent()
await agent.init(request.ask.slots)
market.requested.add request
market.state[request.id] = RequestState.New
await fillSlot(!agent.slotIndex)
await agent.switchAsync(SaleDownloading())
market.emitRequestFulfilled(request.id)
await sleepAsync chronos.seconds(2)
let state = (agent.state as SaleFinished)
check state.isSome
check onProveCalled
check onStoreCalled
check not onClearCalled
check onSaleCalled
test "loads active slots from market":
let me = await market.getSigner()
request.ask.slots = 2
market.requested = @[request]
market.state[request.id] = RequestState.New
let slot0 = MockSlot(requestId: request.id,
slotIndex: 0.u256,
proof: proof,
host: me)
await fillSlot(slot0.slotIndex)
let slot1 = MockSlot(requestId: request.id,
slotIndex: 1.u256,
proof: proof,
host: me)
await fillSlot(slot1.slotIndex)
market.activeSlots[me] = @[request.slotId(0.u256), request.slotId(1.u256)]
await sales.load()
let expected = SalesAgent(sales: sales,
requestId: request.id,
availability: none Availability,
request: some request)
# because sales.load() calls agent.init, we won't know the slotIndex
# randomly selected for the agent, and we also won't know the value of
# `failed`/`fulfilled`/`cancelled` futures, so we need to compare
# the properties we know
proc `==` (agent0, agent1: SalesAgent): bool =
return agent0.sales == agent1.sales and
agent0.requestId == agent1.requestId and
agent0.availability == agent1.availability and
agent0.request == agent1.request
check sales.agents.all(agent => agent == expected)

View File

@ -80,7 +80,7 @@ ethersuite "On-Chain Market":
test "supports request subscriptions": test "supports request subscriptions":
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
var receivedAsks: seq[StorageAsk] var receivedAsks: seq[StorageAsk]
proc onRequest(id: RequestId, ask: StorageAsk) = proc onRequest(id: RequestId, ask: StorageAsk) {.async.} =
receivedIds.add(id) receivedIds.add(id)
receivedAsks.add(ask) receivedAsks.add(ask)
let subscription = await market.subscribeRequests(onRequest) let subscription = await market.subscribeRequests(onRequest)
@ -107,7 +107,7 @@ ethersuite "On-Chain Market":
discard await market.requestStorage(request) discard await market.requestStorage(request)
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
var receivedSlotIndices: seq[UInt256] var receivedSlotIndices: seq[UInt256]
proc onSlotFilled(id: RequestId, slotIndex: UInt256) = proc onSlotFilled(id: RequestId, slotIndex: UInt256) {.async.} =
receivedIds.add(id) receivedIds.add(id)
receivedSlotIndices.add(slotIndex) receivedSlotIndices.add(slotIndex)
let subscription = await market.subscribeSlotFilled(request.id, slotIndex, onSlotFilled) let subscription = await market.subscribeSlotFilled(request.id, slotIndex, onSlotFilled)
@ -121,7 +121,7 @@ ethersuite "On-Chain Market":
await token.approve(storage.address, request.price) await token.approve(storage.address, request.price)
discard await market.requestStorage(request) discard await market.requestStorage(request)
var receivedSlotIndices: seq[UInt256] var receivedSlotIndices: seq[UInt256]
proc onSlotFilled(requestId: RequestId, slotIndex: UInt256) = proc onSlotFilled(requestId: RequestId, slotIndex: UInt256) {.async.} =
receivedSlotIndices.add(slotIndex) receivedSlotIndices.add(slotIndex)
let subscription = await market.subscribeSlotFilled(request.id, slotIndex, onSlotFilled) let subscription = await market.subscribeSlotFilled(request.id, slotIndex, onSlotFilled)
await market.fillSlot(request.id, slotIndex - 1, proof) await market.fillSlot(request.id, slotIndex - 1, proof)
@ -134,7 +134,7 @@ ethersuite "On-Chain Market":
await token.approve(storage.address, request.price) await token.approve(storage.address, request.price)
discard await market.requestStorage(request) discard await market.requestStorage(request)
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
proc onFulfillment(id: RequestId) = proc onFulfillment(id: RequestId) {.async.} =
receivedIds.add(id) receivedIds.add(id)
let subscription = await market.subscribeFulfillment(request.id, onFulfillment) let subscription = await market.subscribeFulfillment(request.id, onFulfillment)
for slotIndex in 0..<request.ask.slots: for slotIndex in 0..<request.ask.slots:
@ -152,7 +152,7 @@ ethersuite "On-Chain Market":
discard await market.requestStorage(otherRequest) discard await market.requestStorage(otherRequest)
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
proc onFulfillment(id: RequestId) = proc onFulfillment(id: RequestId) {.async.} =
receivedIds.add(id) receivedIds.add(id)
let subscription = await market.subscribeFulfillment(request.id, onFulfillment) let subscription = await market.subscribeFulfillment(request.id, onFulfillment)
@ -171,7 +171,7 @@ ethersuite "On-Chain Market":
discard await market.requestStorage(request) discard await market.requestStorage(request)
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
proc onRequestCancelled(id: RequestId) = proc onRequestCancelled(id: RequestId) {.async.} =
receivedIds.add(id) receivedIds.add(id)
let subscription = await market.subscribeRequestCancelled(request.id, onRequestCancelled) let subscription = await market.subscribeRequestCancelled(request.id, onRequestCancelled)
@ -185,7 +185,7 @@ ethersuite "On-Chain Market":
discard await market.requestStorage(request) discard await market.requestStorage(request)
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
proc onRequestFailed(id: RequestId) = proc onRequestFailed(id: RequestId) {.async.} =
receivedIds.add(id) receivedIds.add(id)
let subscription = await market.subscribeRequestFailed(request.id, onRequestFailed) let subscription = await market.subscribeRequestFailed(request.id, onRequestFailed)
@ -213,7 +213,7 @@ ethersuite "On-Chain Market":
discard await market.requestStorage(otherRequest) discard await market.requestStorage(otherRequest)
var receivedIds: seq[RequestId] var receivedIds: seq[RequestId]
proc onRequestCancelled(requestId: RequestId) = proc onRequestCancelled(requestId: RequestId) {.async.} =
receivedIds.add(requestId) receivedIds.add(requestId)
let subscription = await market.subscribeRequestCancelled(request.id, onRequestCancelled) let subscription = await market.subscribeRequestCancelled(request.id, onRequestCancelled)
@ -244,3 +244,27 @@ ethersuite "On-Chain Market":
for slotIndex in 0..<request.ask.slots: for slotIndex in 0..<request.ask.slots:
await market.fillSlot(request.id, slotIndex.u256, proof) await market.fillSlot(request.id, slotIndex.u256, proof)
check (await market.getState(request.id)) == RequestState.Started check (await market.getState(request.id)) == RequestState.Started
test "can retrieve active slots":
await token.approve(storage.address, request.price)
discard await market.requestStorage(request)
await market.fillSlot(request.id, slotIndex - 1, proof)
await market.fillSlot(request.id, slotIndex, proof)
let slotId1 = request.slotId(slotIndex - 1)
let slotId2 = request.slotId(slotIndex)
check (await market.mySlots()) == @[slotId1, slotId2]
test "returns none when slot is empty":
await token.approve(storage.address, request.price)
discard await market.requestStorage(request)
let slotId = request.slotId(slotIndex)
check (await market.getSlot(slotId)) == none Slot
test "can retrieve slot details":
await token.approve(storage.address, request.price)
discard await market.requestStorage(request)
await market.fillSlot(request.id, slotIndex, proof)
let slotId = request.slotId(slotIndex)
let expected = Slot(host: request.client,
requestId: request.id)
check (await market.getSlot(slotId)) == some expected