diff --git a/codex/contracts/market.nim b/codex/contracts/market.nim index f4fc2161..81222360 100644 --- a/codex/contracts/market.nim +++ b/codex/contracts/market.nim @@ -69,7 +69,7 @@ method getHost(market: OnChainMarket, else: return none Address -method getSlot(market: OnChainMarket, slotId: SlotId): Future[?Slot] {.async.} = +method getSlot*(market: OnChainMarket, slotId: SlotId): Future[?Slot] {.async.} = try: return some await market.contract.getSlot(slotId) except ProviderError as e: diff --git a/codex/contracts/requests.nim b/codex/contracts/requests.nim index a0c6fd1c..43ec8a32 100644 --- a/codex/contracts/requests.nim +++ b/codex/contracts/requests.nim @@ -39,12 +39,10 @@ type Cancelled Finished Failed - Slot* = object + Slot* = object of RootObj host*: Address hostPaid*: bool requestId*: RequestId - slotIndex*: UInt256 - proof*: seq[byte] proc `==`*(x, y: Nonce): bool {.borrow.} proc `==`*(x, y: RequestId): bool {.borrow.} @@ -117,6 +115,9 @@ func solidityType*(_: type StorageAsk): string = func solidityType*(_: type StorageRequest): string = solidityType(StorageRequest.fieldTypes) +func solidityType*(_: type Slot): string = + solidityType(Slot.fieldTypes) + func solidityType*[T: RequestId | SlotId | Nonce](_: type T): string = solidityType(array[32, byte]) diff --git a/codex/utils/statemachine.nim b/codex/utils/statemachine.nim index 51c3ac15..8f5050bb 100644 --- a/codex/utils/statemachine.nim +++ b/codex/utils/statemachine.nim @@ -111,7 +111,6 @@ method exit(state: AsyncState) = proc switchAsync*(machine: StateMachineAsync, newState: AsyncState) {.async.} = if state =? (machine.state as AsyncState): trace "Switching sales state", `from`=state, to=newState - debugEcho "switching from ", state, " to ", newState if activeTransition =? state.activeTransition and not activeTransition.completed: await activeTransition.cancelAndWait() @@ -121,7 +120,6 @@ proc switchAsync*(machine: StateMachineAsync, newState: AsyncState) {.async.} = state.context = none StateMachine else: trace "Switching sales state", `from`="no state", to=newState - debugEcho "switching from no state to ", newState machine.state = some State(newState) newState.context = some StateMachine(machine) diff --git a/tests/codex/helpers/mockmarket.nim b/tests/codex/helpers/mockmarket.nim index 68ef6dff..910fd475 100644 --- a/tests/codex/helpers/mockmarket.nim +++ b/tests/codex/helpers/mockmarket.nim @@ -2,6 +2,7 @@ import std/sequtils import std/tables import std/hashes import pkg/codex/market +import pkg/codex/contracts/requests export market export tables @@ -14,7 +15,7 @@ type requestEnds*: Table[RequestId, SecondsSince1970] state*: Table[RequestId, RequestState] fulfilled*: seq[Fulfillment] - filled*: seq[Slot] + filled*: seq[MockSlot] withdrawn*: seq[RequestId] signer: Address subscriptions: Subscriptions @@ -22,6 +23,9 @@ type requestId*: RequestId proof*: seq[byte] host*: Address + MockSlot* = object of Slot + slotIndex*: UInt256 + proof*: seq[byte] Subscriptions = object onRequest: seq[RequestSubscription] onFulfillment: seq[FulfillmentSubscription] @@ -83,6 +87,14 @@ method getRequest(market: MockMarket, return some request 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, requestId: RequestId): Future[?RequestState] {.async.} = return market.state.?[requestId] @@ -132,7 +144,7 @@ proc fillSlot*(market: MockMarket, slotIndex: UInt256, proof: seq[byte], host: Address) = - let slot = Slot( + let slot = MockSlot( requestId: requestId, slotIndex: slotIndex, proof: proof, diff --git a/tests/codex/testsales.nim b/tests/codex/testsales.nim index 62eb78cb..ab348749 100644 --- a/tests/codex/testsales.nim +++ b/tests/codex/testsales.nim @@ -1,9 +1,13 @@ import std/sets +import std/sequtils +import std/sugar import std/times import pkg/asynctest import pkg/chronos -import pkg/codex/contracts/requests +# import pkg/codex/contracts/requests import pkg/codex/sales +import pkg/codex/sales/states/[downloading, cancelled, errored, filled, filling, + failed, proving, finished, unknown] import ./helpers/mockmarket import ./helpers/mockclock import ./examples @@ -214,3 +218,301 @@ suite "Sales": discard await market.requestStorage(request) check proving.slots.len == 1 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) diff --git a/tests/contracts/testMarket.nim b/tests/contracts/testMarket.nim index 7448128e..6b085f55 100644 --- a/tests/contracts/testMarket.nim +++ b/tests/contracts/testMarket.nim @@ -80,7 +80,7 @@ ethersuite "On-Chain Market": test "supports request subscriptions": var receivedIds: seq[RequestId] var receivedAsks: seq[StorageAsk] - proc onRequest(id: RequestId, ask: StorageAsk) = + proc onRequest(id: RequestId, ask: StorageAsk) {.async.} = receivedIds.add(id) receivedAsks.add(ask) let subscription = await market.subscribeRequests(onRequest) @@ -107,7 +107,7 @@ ethersuite "On-Chain Market": discard await market.requestStorage(request) var receivedIds: seq[RequestId] var receivedSlotIndices: seq[UInt256] - proc onSlotFilled(id: RequestId, slotIndex: UInt256) = + proc onSlotFilled(id: RequestId, slotIndex: UInt256) {.async.} = receivedIds.add(id) receivedSlotIndices.add(slotIndex) let subscription = await market.subscribeSlotFilled(request.id, slotIndex, onSlotFilled) @@ -121,7 +121,7 @@ ethersuite "On-Chain Market": await token.approve(storage.address, request.price) discard await market.requestStorage(request) var receivedSlotIndices: seq[UInt256] - proc onSlotFilled(requestId: RequestId, slotIndex: UInt256) = + proc onSlotFilled(requestId: RequestId, slotIndex: UInt256) {.async.} = receivedSlotIndices.add(slotIndex) let subscription = await market.subscribeSlotFilled(request.id, slotIndex, onSlotFilled) await market.fillSlot(request.id, slotIndex - 1, proof) @@ -134,7 +134,7 @@ ethersuite "On-Chain Market": await token.approve(storage.address, request.price) discard await market.requestStorage(request) var receivedIds: seq[RequestId] - proc onFulfillment(id: RequestId) = + proc onFulfillment(id: RequestId) {.async.} = receivedIds.add(id) let subscription = await market.subscribeFulfillment(request.id, onFulfillment) for slotIndex in 0..