Purchase module specification

1. Purpose and Scope

The Purchase module manages the lifecycle of a storage agreement between a client and a host in Codex. It ensures that each purchase is correctly initialized, tracked, and completed according to the state of its corresponding StorageRequest in the marketplace.

Purchases are implemented as a state machine that progresses through defined states until reaching a deterministic terminal state (finished, cancelled, failed, or errored).

The StorageRequest contains all necessary data for the agreement (CID, collateral, expiry, etc.) and is stored in the marketplace dependency.

In this document, on-chain refers to interactions with that marketplace, but the abstraction could be replaceable: it could point to a different backend or custom storage system in future implementations.


2. Interfaces

Interface (Nim)DescriptionInputOutput
func new(_: type Purchase, requestId: RequestId, market: Market, clock: Clock): PurchaseConstruct a purchase from a storage request identifier. Used to recover a purchase.requestId: RequestId, market: Market, clock: ClockPurchase
func new(_: type Purchase, request: StorageRequest, market: Market, clock: Clock): PurchaseCreate a purchase from a full StorageRequest.request: StorageRequest, market: Market, clock: ClockPurchase
proc start*(purchase: Purchase)Start the state machine in pending mode (new on-chain submission flow).purchase: Purchasevoid
proc load*(purchase: Purchase)Start the state machine in unknown mode (restore/recover after restart).purchase: Purchasevoid
proc wait*(purchase: Purchase) {.async.}Await terminal state: completes on success or raises on failure.purchase: PurchaseFuture[void]
func id*(purchase: Purchase): PurchaseIdStable identifier derived from requestId.purchase: PurchasePurchaseId
func finished*(purchase: Purchase): boolCheck whether the purchase completed successfully.purchase: Purchasebool
func error*(purchase: Purchase): ?(ref CatchableError)Get error if the purchase failed.purchase: PurchaseOption[ref CatchableError]
func state*(purchase: Purchase): ?stringQuery current state name via the state machine.purchase: PurchaseOption[string]
proc hash*(x: PurchaseId): HashCompute hash for PurchaseId.x: PurchaseIdHash
proc ==*(x, y: PurchaseId): boolEquality comparison for PurchaseId.x: PurchaseId, y: PurchaseIdbool
proc toHex*(x: PurchaseId): stringHex string representation of PurchaseId.x: PurchaseIdstring
method run*(state: PurchasePending, machine: Machine): Future[?State] {.async: (raises: []).}Submit request to market.state: PurchasePending, machine: MachineFuture[Option[State]]
method run*(state: PurchaseSubmitted, machine: Machine): Future[?State] {.async: (raises: []).}Await purchase start.state: PurchaseSubmitted, machine: MachineFuture[Option[State]]
method run*(state: PurchaseStarted, machine: Machine): Future[?State] {.async: (raises: []).}Run the purchase.state: PurchaseStarted, machine: MachineFuture[Option[State]]
method run*(state: PurchaseFinished, machine: Machine): Future[?State] {.async: (raises: []).}Purchase completed.state: PurchaseFinished, machine: MachineFuture[Option[State]]
method run*(state: PurchaseErrored, machine: Machine): Future[?State] {.async: (raises: []).}Purchase failed.state: PurchaseErrored, machine: MachineFuture[Option[State]]
method run*(state: PurchaseCancelled, machine: Machine): Future[?State] {.async: (raises: []).}Purchase cancelled or timed out.state: PurchaseCancelled, machine: MachineFuture[Option[State]]
method run*(state: PurchaseUnknown, machine: Machine): Future[?State] {.async: (raises: []).}Recover a purchase.state: PurchaseUnknown, machine: MachineFuture[Option[State]]

3. Functional Requirements (what it must do)

3.1 Definition

  • Every purchase represents exactly oneStorageRequest.
  • The purchase must have a unique, deterministic identifier PurchaseId derived from requestId.
  • It must be possible to restore any purchase from its requestId after a restart.
  • A purchase is considered expired when the expiry timestamp in its StorageRequest is reached before the request start, i.e, an event RequestFulfilled is emitted by the marketplace.

3.2 State Machine Progression

  • New purchases start in the pending state (submission flow).
  • Recovered purchases start in the unknown state (recovery flow).
  • The state machine progresses step-by-step until a deterministic terminal state (finished, cancelled, failed, or errored) is reached.
  • The choice of terminal state is based on the RequestState returned by the marketplace.

3.3 Failure Handling

  • On marketplace failure events, immediately transition to errored without retries.
  • If a CancelledError is raised, log the cancellation and stop further processing.
  • If a CatchableError is raised, transition to errored and record the error.

4. Non-Functional Requirements (how it should behave)

  • Execution model: A purchase is handled by a single thread; only one worker should process a given purchase instance at a time.
  • Reliability: load supports recovery after process restarts.
  • Performance: State transitions should be non-blocking; all I/O is async.
  • Logging: All state transitions and errors should be clearly logged for traceability.
  • Safety:
    • Avoid side effects during new other than initialising internal fields; on-chain interactions are delegated to states using marketplace dependency.
    • Retry policy for external calls.
  • Testing:
    • Unit tests check that each state handles success and error properly.
    • Integration tests check that a full purchase flows correctly through states.

5. Internal Behavior

5.1 State identifiers

  • PurchasePending: pending
  • PurchaseSubmitted: submitted
  • PurchaseStarted: started
  • PurchaseFinished: finished
  • PurchaseErrored: errored
  • PurchaseCancelled: cancelled
  • PurchaseFailed: failed
  • PurchaseUnknown: unknown

5.2 General rules for all states

  • If a CancelledError is raised, the state machine logs the cancellation message and takes no further action.
  • If a CatchableError is raised, the state machine moves to errored with the error message.

5.3 State descriptions

pending: A storage request is being created by making a call on-chain. If the storage request creation fails, the state machine moves to the errored state with the corresponding error.

submitted: The storage request has been created and the purchase waits for the request to start. When it starts, an on-chain event RequestFulfilled is emitted, triggering the subscription callback, and the state machine moves to the started state. If the expiry is reached before the callback is called, the state machine moves to the cancelled state.

started: At this point, the purchase is active and waits until the end of the request—defined by the storage request parameters, before moving to the finished state. A subscription is made to the marketplace to be notified about request failure. If a request failure is notified, the state machine moves to failed.
marketplace subscription signature:

method subscribeRequestFailed*(market: Market, requestId: RequestId, callback: OnRequestFailed): Future[Subscription] {.base, async.}

finished: The purchase is considered successful and cleanup routines are called. The purchase module calls marketplace.withdrawFunds to release the funds locked by the marketplace:

method withdrawFunds*(market: Market, requestId: RequestId) {.base, async: (raises: [CancelledError, MarketError]).}

After that, the purchase is done; no more states are called and the state machine stops successfully.

failed: If the marketplace emits a RequestFailed event, the state machine moves to the failed state and the purchase module calls marketplace.withdrawFunds (same signature as above) to release the funds locked by the marketplace. After that, the state machine moves to errored.

cancelled: The purchase is cancelled and the purchase module calls marketplace.withdrawFunds to release the funds locked by the marketplace (same signature as above). After that, the purchase is terminated; no more states are called and the state machine stops with the reason of failure as error.

errored: The purchase is terminated; no more states are called and the state machine stops with the reason of failure as error.

unknown: The purchase is in recovery mode, meaning that the state has to be determined. The purchase module calls the marketplace to get the request data (getRequest) and the request state (requestState):

method getRequest*(market: Market, id: RequestId): Future[?StorageRequest] {.base, async: (raises: [CancelledError]).}
 
method requestState*(market: Market, requestId: RequestId): Future[?RequestState] {.base, async.}

Based on this information, it moves to the corresponding next state.

5.4 State diagram

                                                                      v
                                         ------------------------- unknown
        |                               /                             /
        v                              v                             /
     pending ----> submitted ----> started ---------> finished <----/
                        \              \                           /
                         \              ------------> failed <----/
                          \                                      /
                           --> cancelled <-----------------------

Note: Any state can transition to errored upon a CatchableError. failed is an intermediate state before errored. finished, cancelled, and errored are terminal states.


6. Dependencies

  • marketplace: External dependency used to submit and monitor storage requests (requestStorage, withdrawFunds, subscriptions for request events).
  • clock: Provides timing utilities, used for expiry and scheduling logic.
  • nim-chronos: Async runtime used for futures, awaiting I/O, and cancellation handling.
  • asyncstatemachine: Base state machine framework used to implement the purchase lifecycle.
  • hashes: Standard Nim hashing for PurchaseId.
  • questionable: Used for optional fields like request.

7. Data Models

Purchase

Purchase* = ref object of Machine
  future*: Future[void]
  market*: Market
  clock*: Clock
  requestId*: RequestId
  request*: ?StorageRequest

Storage request

StorageRequest* = object
  client* {.serialize.}: Address
  ask* {.serialize.}: StorageAsk
  content* {.serialize.}: StorageContent
  expiry* {.serialize.}: uint64
  nonce*: Nonce

Storage ask

StorageAsk* = object
  proofProbability* {.serialize.}: UInt256
  pricePerBytePerSecond* {.serialize.}: UInt256
  collateralPerByte* {.serialize.}: UInt256
  slots* {.serialize.}: uint64
  slotSize* {.serialize.}: uint64
  duration* {.serialize.}: uint64
  maxSlotLoss* {.serialize.}: uint64

Storage content

StorageContent* = object
  cid* {.serialize.}: Cid
  merkleRoot*: array[32, byte]

RequestId

RequestId* = distinct array[32, byte]

Nonce

Nonce* = distinct array[32, byte]