13 KiB
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) | Description | Input | Output |
|---|---|---|---|
func new(_: type Purchase, requestId: RequestId, market: Market, clock: Clock): Purchase |
Construct a purchase from a storage request identifier. Used to recover a purchase. | requestId: RequestId, market: Market, clock: Clock |
Purchase |
func new(_: type Purchase, request: StorageRequest, market: Market, clock: Clock): Purchase |
Create a purchase from a full StorageRequest. |
request: StorageRequest, market: Market, clock: Clock |
Purchase |
proc start*(purchase: Purchase) |
Start the state machine in pending mode (new on-chain submission flow). | purchase: Purchase |
void |
proc load*(purchase: Purchase) |
Start the state machine in unknown mode (restore/recover after restart). | purchase: Purchase |
void |
proc wait*(purchase: Purchase) {.async.} |
Await terminal state: completes on success or raises on failure. | purchase: Purchase |
Future[void] |
func id*(purchase: Purchase): PurchaseId |
Stable identifier derived from requestId. |
purchase: Purchase |
PurchaseId |
func finished*(purchase: Purchase): bool |
Check whether the purchase completed successfully. | purchase: Purchase |
bool |
func error*(purchase: Purchase): ?(ref CatchableError) |
Get error if the purchase failed. | purchase: Purchase |
Option[ref CatchableError] |
func state*(purchase: Purchase): ?string |
Query current state name via the state machine. | purchase: Purchase |
Option[string] |
proc hash*(x: PurchaseId): Hash |
Compute hash for PurchaseId. |
x: PurchaseId |
Hash |
proc ==*(x, y: PurchaseId): bool |
Equality comparison for PurchaseId. |
x: PurchaseId, y: PurchaseId |
bool |
proc toHex*(x: PurchaseId): string |
Hex string representation of PurchaseId. |
x: PurchaseId |
string |
method run*(state: PurchasePending, machine: Machine): Future[?State] {.async: (raises: []).} |
Submit request to market. | state: PurchasePending, machine: Machine |
Future[Option[State]] |
method run*(state: PurchaseSubmitted, machine: Machine): Future[?State] {.async: (raises: []).} |
Await purchase start. | state: PurchaseSubmitted, machine: Machine |
Future[Option[State]] |
method run*(state: PurchaseStarted, machine: Machine): Future[?State] {.async: (raises: []).} |
Run the purchase. | state: PurchaseStarted, machine: Machine |
Future[Option[State]] |
method run*(state: PurchaseFinished, machine: Machine): Future[?State] {.async: (raises: []).} |
Purchase completed. | state: PurchaseFinished, machine: Machine |
Future[Option[State]] |
method run*(state: PurchaseErrored, machine: Machine): Future[?State] {.async: (raises: []).} |
Purchase failed. | state: PurchaseErrored, machine: Machine |
Future[Option[State]] |
method run*(state: PurchaseCancelled, machine: Machine): Future[?State] {.async: (raises: []).} |
Purchase cancelled or timed out. | state: PurchaseCancelled, machine: Machine |
Future[Option[State]] |
method run*(state: PurchaseUnknown, machine: Machine): Future[?State] {.async: (raises: []).} |
Recover a purchase. | state: PurchaseUnknown, machine: Machine |
Future[Option[State]] |
3. Functional Requirements (what it must do)
3.1 Definition
- Every purchase represents exactly one
StorageRequest. - The purchase must have a unique, deterministic identifier
PurchaseIdderived fromrequestId. - It must be possible to restore any purchase from its
requestIdafter a restart. - A purchase is considered expired when the expiry timestamp in its
StorageRequestis reached before the request start, i.e, an eventRequestFulfilledis emitted by themarketplace.
3.2 State Machine Progression
- New purchases start in the
pendingstate (submission flow). - Recovered purchases start in the
unknownstate (recovery flow). - The state machine progresses step-by-step until a deterministic terminal state (
finished,cancelled,failed, orerrored) is reached. - The choice of terminal state is based on the
RequestStatereturned by themarketplace.
3.3 Failure Handling
- On
marketplacefailure events, immediately transition toerroredwithout retries. - If a
CancelledErroris raised, log the cancellation and stop further processing. - If a
CatchableErroris raised, transition toerroredand 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:
loadsupports 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
newother than initialising internal fields;on-chaininteractions are delegated to states usingmarketplacedependency. - Retry policy for external calls.
- Avoid side effects during
- 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
CancelledErroris raised, the state machine logs the cancellation message and takes no further action. - If a
CatchableErroris raised, the state machine moves toerroredwith 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]