Merge pull request #9 from codex-storage/specs/purchase-module

Purchase module specification
This commit is contained in:
Arnaud 2025-08-15 20:13:53 +02:00 committed by GitHub
commit 6110c6dea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -0,0 +1,219 @@
# 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 `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:
```nim
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`:
```nim
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`):
```nim
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`
```nim
Purchase* = ref object of Machine
future*: Future[void]
market*: Market
clock*: Clock
requestId*: RequestId
request*: ?StorageRequest
```
### Storage request
```nim
StorageRequest* = object
client* {.serialize.}: Address
ask* {.serialize.}: StorageAsk
content* {.serialize.}: StorageContent
expiry* {.serialize.}: uint64
nonce*: Nonce
```
### Storage ask
```nim
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
```nim
StorageContent* = object
cid* {.serialize.}: Cid
merkleRoot*: array[32, byte]
```
### RequestId
```nim
RequestId* = distinct array[32, byte]
```
### Nonce
```nim
Nonce* = distinct array[32, byte]
```