From 34bf6008ce8f5f50d8eb384b799c3579f082a45d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 15 Aug 2024 12:08:15 +0200 Subject: [PATCH] Add marketplace endpoints --- .editorconfig | 10 + .gitignore | 2 + .npmignore | 2 + README.md | 170 ++++++++++++++ package-lock.json | 64 +++++ package.json | 42 ++++ src/api/config.ts | 5 + src/disk/disk.ts | 19 ++ src/errors/errors.ts | 51 ++++ src/fetch-safe/fetch-safe.test.ts | 88 +++++++ src/fetch-safe/fetch-safe.ts | 34 +++ src/index.ts | 18 ++ src/marketplace/marketplace.test.ts | 347 ++++++++++++++++++++++++++++ src/marketplace/marketplace.ts | 173 ++++++++++++++ src/marketplace/types.ts | 195 ++++++++++++++++ src/values/values.ts | 10 + tsconfig.json | 15 ++ 17 files changed, 1245 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api/config.ts create mode 100644 src/disk/disk.ts create mode 100644 src/errors/errors.ts create mode 100644 src/fetch-safe/fetch-safe.test.ts create mode 100644 src/fetch-safe/fetch-safe.ts create mode 100644 src/index.ts create mode 100644 src/marketplace/marketplace.test.ts create mode 100644 src/marketplace/marketplace.ts create mode 100644 src/marketplace/types.ts create mode 100644 src/values/values.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..63a0dd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..235dbbb --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +src +.editorconfig diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9d81c7 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# Codex SDK + +The Codex SDK provides an API for interacting with the Codex decentralized storage network. + +## Import + +```js +import { Codex } from "@codex/sdk-js"; +``` + +or + +```js +const { Codex } = require("@codex/sdk-js"); +``` + +## How to use + +To create a Codex instance, provide the REST API url to interact with the Codex client: + +```js +const codex = new Codex("http://localhost:3000") +``` + +### Error handling + +The SDK provides a type called `SafeValue` for error handling instead of throwing errors. It is inspired by Go's "error as value" concept. +If the value represents an error, `error` is true and `data` will contain the error. +If the value is not an error, `error` is false and `data` will contain the requested data. + +The error type is a [CodexError](./src/errors/errors.ts#L15) which can be error object of 3 types: + +* `error`: Object containing the error message +* `api`: Object containing the api error message and the status code +* `validation`: Object containing the error message and a field `errors` of type [ValidationError](./src/errors/errors.ts#L3) containing the error message for each fields. + +Example: + +```js +const slots = await codex.marketplace.activeSlots(); + +if (slots.error) { + // Do something to handle the error in slots.data + return +} + +// Access the slots within slots.data. +``` + +### Marketplace + +#### activeSlots() + +Returns active slots. + +- returns Promise<[CodexSlot](./src/marketplace/types.ts#L86)[]> + +Example: + +```js +const slots = await codex.marketplace.activeSlots(); +``` + +#### activeSlot(slotId) + +Returns active slot with id {slotId} for the host. + +- slotId (string, required) +- returns Promise<[CodexSlot](./src/marketplace/types.ts#L86)[]> + +Example: + +```js +const slotId= "AB9........" +const slot = await codex.marketplace.activeSlot(slotId); +``` + + +#### availabilities + +Returns storage that is for sale. + +- returns Promise<[CodexAvailability](./src/marketplace/types.ts#L100)> + +Example: + +```js +const availabilities = await codex.marketplace.availabilities(); +``` + +#### createAvailability + +Offers storage for sale. + +- input ([CodexCreateAvailabilityInput](./src/marketplace/types.ts#L133), required) +- returns Promise<[CodexAvailabilityCreateResponse](./src/marketplace/types.ts#L124)[]> + +Example: + +```js +const response = await codex.marketplace.createAvailability({ + maxCollateral: 1, + totalSize: 3000, + minPrice: 100, + duration: 100, +}); +``` + +#### reservations + +Return list of reservations for ongoing Storage Requests that the node hosts. + +- availabilityId (string, required) +- returns Promise<[CodexReservation](./src/marketplace/types.ts#L152)[]> + +Example: + +```js +const reservations = await codex.marketplace.reservations("Ox..."); +``` + + +#### createStorageRequest + +Creates a new Request for storage + +- input ([CodexCreateStorageRequestInput](./src/marketplace/types.ts#L182), required) +- returns Promise<[CodexCreateStorageRequestResponse](./src/marketplace/types.ts#L195)[]> + +Example: + +```js +const request = await codex.marketplace.createStorageRequest({ + duration: 3000, + reward: 100, + proofProbability: 1, + nodes: 1, + tolerance: 0, + collateral: 100, + expiry: 3000 +}); +``` + + +#### purchaseIds + +Returns list of purchase IDs + +- returns Promise + + +Example: + +```js +const ids = await codex.marketplace.purchaseIds(); +``` + +#### purchaseDetail + +Returns list of purchase IDs + +- purchaseId (string, required) +- returns Promise<[CodexPurchase](./src/marketplace/types.ts#L168)[]> + +Example: + +```js +const purchaseId = "Ox........" +const purchase = await codex.marketplace.purchaseDetail(purchaseId); +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..10c142b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,64 @@ +{ + "name": "@codex/sdk-js", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@codex/sdk-js", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.32.35" + }, + "devDependencies": { + "@faker-js/faker": "^8.4.1", + "@tsconfig/strictest": "^2.0.5", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.32.35", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.35.tgz", + "integrity": "sha512-Ul3YyOTU++to8cgNkttakC0dWvpERr6RYoHO2W47DLbFvrwBDJUY31B1sImH6JZSYc4Kt4PyHtoPNu+vL2r2dA==" + }, + "node_modules/@tsconfig/strictest": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@tsconfig/strictest/-/strictest-2.0.5.tgz", + "integrity": "sha512-ec4tjL2Rr0pkZ5hww65c+EEPYwxOi4Ryv+0MtjeaSQRJyq322Q27eOQiFbuNgw2hpL4hB1/W/HBGk3VKS43osg==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..41860ca --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "@codex/sdk-js", + "version": "0.0.1", + "description": "Codex SDK to interact with the Codex decentralized storage network.", + "main": "index.js", + "scripts": { + "prepack": "npm run build", + "prebuild": "rm -Rf dist/*", + "build": "tsc", + "compile": "tsc --noEmit", + "pretest": "npm run build", + "test": "node --test", + "watch": "tsc --watch" + }, + "keywords": [ + "Codex", + "Javascript", + "SDK", + "storage" + ], + "files": [ + "dist/**" + ], + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "author": "Codex team", + "readme": "README.md", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@faker-js/faker": "^8.4.1", + "@tsconfig/strictest": "^2.0.5", + "typescript": "^5.5.4" + }, + "dependencies": { + "@sinclair/typebox": "^0.32.35" + } +} \ No newline at end of file diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..be5159f --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,5 @@ +export const Api = { + config: { + prefix: "/api/codex/v1" + } +} \ No newline at end of file diff --git a/src/disk/disk.ts b/src/disk/disk.ts new file mode 100644 index 0000000..bccac22 --- /dev/null +++ b/src/disk/disk.ts @@ -0,0 +1,19 @@ +import { SafeValue } from "../values/values" + +export class Disk { + readonly url: string + + constructor(url: string) { + this.url = url + } + + async available(): Promise> { + return { + error: false, + data: { + full: 500, + used: 200 + } + } + } +} diff --git a/src/errors/errors.ts b/src/errors/errors.ts new file mode 100644 index 0000000..86615be --- /dev/null +++ b/src/errors/errors.ts @@ -0,0 +1,51 @@ +import { ValueErrorIterator } from "@sinclair/typebox/build/cjs/errors" + +type ValidationError = { + path: string + message: string +} + +/** + * The CodexError which can be error object of 3 types: + * `error`: Object containing the error message + * `api`: Object containing the api error message and the status code + * `validation`: Object containing the error message and a field `errors` of type ValidationError + * containing the error message for each fields. + */ +export type CodexError = { + type: "error" + message: string +} | { + type: "api" + message: string + status: number +} | { + type: "validation" + message: string + errors: ValidationError[] +} + +export const CodexValidationErrors = { + map(iterator: ValueErrorIterator) { + let error + const errors = [] + + while (error = iterator.First()) { + errors.push({ + path: error.path, + message: error.message + }) + } + + return errors + } +} + +// export class CodexError extends Error { +// readonly status: number | null + +// constructor(message: string, status: number | null = null) { +// super(message) +// this.status = status +// } +// } diff --git a/src/fetch-safe/fetch-safe.test.ts b/src/fetch-safe/fetch-safe.test.ts new file mode 100644 index 0000000..5055b32 --- /dev/null +++ b/src/fetch-safe/fetch-safe.test.ts @@ -0,0 +1,88 @@ +import assert from "assert"; +import { describe, it } from "node:test"; +import { Fetch } from '../fetch-safe/fetch-safe'; + +class MockResponse implements Response { + headers: Headers = new Headers() + ok: boolean; + redirected = false + status: number; + statusText = ""; + type = "basic" as "basic" + url = "" + body = null; + bodyUsed = false; + _text: string + + constructor(ok: boolean, status: number, text: string) { + this.ok = ok + this.status = status + this._text = text + } + + clone(): Response { + throw new Error("Method not implemented."); + } + + arrayBuffer(): Promise { + throw new Error("Method not implemented."); + } + + blob(): Promise { + throw new Error("Method not implemented."); + } + + formData(): Promise { + throw new Error("Method not implemented."); + } + + json(): Promise { + return Promise.resolve(JSON.parse(this._text)) + } + + text(): Promise { + return Promise.resolve(this._text) + } +} + +describe("fetch", () => { + it("returns an error when the http call failed", async (t) => { + global.fetch = t.mock.fn(() => + Promise.resolve(new MockResponse(false, 500, "error")), + ); + + const result = await Fetch.safe("http://localhost:3000/some-url", { method: "GET" }) + const error = { + type: "api", + message: "error", + status: 500 + } + + assert.deepStrictEqual(result, { error: true, data: error }); + }); + + it("returns an error when the json parsing failed", async (t) => { + global.fetch = t.mock.fn(() => + Promise.resolve(new MockResponse(true, 200, "")), + ); + + const result = await Fetch.safe("http://localhost:3000/some-url", { method: "GET" }) + const error = { + type: "error", + message: "Unexpected end of JSON input" + } + + assert.deepStrictEqual(result, { error: true, data: error }); + }); + + + it("returns the data when the fetch succeed", async (t) => { + global.fetch = t.mock.fn(() => + Promise.resolve(new MockResponse(true, 200, JSON.stringify({ hello: "world" }))), + ); + + const result = await Fetch.safe("http://localhost:3000/some-url", { method: "GET" }) + + assert.deepStrictEqual(result, { error: false, data: { hello: "world" } }); + }); +}); diff --git a/src/fetch-safe/fetch-safe.ts b/src/fetch-safe/fetch-safe.ts new file mode 100644 index 0000000..7edee69 --- /dev/null +++ b/src/fetch-safe/fetch-safe.ts @@ -0,0 +1,34 @@ +import { SafeValue } from "../values/values" + +export const Fetch = { + async safe(url: string, init: RequestInit): Promise> { + const res = await fetch(url, init) + + if (!res.ok) { + const message = await res.text() + + return { + error: true, + data: { + type: "api", + message, + status: res.status + } + } + } + + try { + const json = await res.json() + + return { error: false, data: json } + } catch (e) { + return { + error: true, + data: { + type: "error", + message: e instanceof Error ? e.message : "JSON parsing error :" + e + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7a3b20a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,18 @@ +import { Disk } from "./disk/disk"; +import { Marketplace } from "./marketplace/marketplace"; + +export * from "./fetch-safe/fetch-safe"; +export * from "./marketplace/types"; + +export class Codex { + readonly url: string + readonly marketplace: Marketplace + readonly disk: Disk + + constructor(url: string) { + this.url = url + this.marketplace = new Marketplace(url) + this.disk = new Disk(url) + } +} + diff --git a/src/marketplace/marketplace.test.ts b/src/marketplace/marketplace.test.ts new file mode 100644 index 0000000..37d90bb --- /dev/null +++ b/src/marketplace/marketplace.test.ts @@ -0,0 +1,347 @@ +import { faker } from "@faker-js/faker"; +import assert from "assert"; +import { describe, it } from "node:test"; +import { Fetch } from "../fetch-safe/fetch-safe"; +import { Marketplace } from "./marketplace"; + +// function createSlot() { +// return { +// "id": faker.string.alphanumeric(64), +// "request": { + +// "id": faker.string.alphanumeric(64), +// "client": faker.finance.ethereumAddress(), +// "ask": +// { +// "slots": faker.number.int({ min: 0, max: 9 }), +// "slotSize": faker.number.float({ max: 10000 }).toString(), +// "duration": faker.number.int({ max: 300000 }).toString(), +// "proofProbability": faker.number.int({ max: 9 }), +// "reward": faker.number.float({ max: 1000 }).toString(), +// "maxSlotLoss": faker.number.int({ max: 9 }) +// }, +// "content": { +// "cid": faker.string.alphanumeric(64), +// "por": { +// "u": faker.string.alphanumeric(16), +// "publicKey": faker.string.alphanumeric(64), +// "name": faker.string.alphanumeric(16) +// } +// }, +// "expiry": faker.number.int({ min: 2, max: 59 }) + " minutes", +// "nonce": faker.string.alphanumeric(64) +// }, +// "slotIndex": faker.number.int({ min: 0, max: 9 }) +// } +// } + +function createStorageRequest() { + return { + cid: faker.string.alphanumeric(64), + duration: faker.number.int({ min: 1 }), + reward: faker.number.int(), + proofProbability: faker.number.int(), + nodes: faker.number.int(), + tolerance: faker.number.int(), + expiry: faker.number.int({ min: 1 }), + collateral: faker.number.int() + } +} + +function missingNumberValidationError(field: string) { + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: [ + { + path: "/" + field, + message: "Expected required property" + }, + { + path: "/" + field, + message: "Expected number" + } + ] + } + } +} + +function missingStringValidationError(field: string) { + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: [ + { + path: "/" + field, + message: "Expected required property" + }, + { + path: "/" + field, + message: "Expected string" + } + ] + } + } +} + +function mistypeNumberValidationError(field: string) { + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: [ + { + path: "/" + field, + message: "Expected number" + }, + ] + } + } +} + +function minNumberValidationError(field: string, min: number) { + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: [ + { + path: "/" + field, + message: "Expected number to be greater or equal to " + min + } + ] + } + } +} + +function createAvailability() { + return { + "id": faker.finance.ethereumAddress(), + "availabilityId": faker.finance.ethereumAddress(), + "size": faker.number.int({ min: 3000, max: 300000 }), + "requestId": faker.finance.ethereumAddress(), + "slotIndex": faker.number.int({ min: 0, max: 9 }) + } +} + +describe("marketplace", () => { + const marketplace = new Marketplace("http://localhost:3000") + + it("returns an error when trying to create an availability without total size", async () => { + const response = await marketplace.createAvailability({ + duration: 3000, + maxCollateral: 1, + minPrice: 100, + } as any) + + assert.deepStrictEqual(response, missingNumberValidationError("totalSize")); + }); + + it("returns an error when trying to create an availability with an invalid number valid", async () => { + const response = await marketplace.createAvailability({ + duration: 3000, + maxCollateral: 1, + minPrice: 100, + totalSize: "abc" + } as any) + + assert.deepStrictEqual(response, mistypeNumberValidationError("totalSize")); + }); + + it("returns an error when trying to create an availability with zero total size", async () => { + const response = await marketplace.createAvailability({ + duration: 3000, + maxCollateral: 1, + minPrice: 100, + totalSize: 0 + }) + + assert.deepStrictEqual(response, minNumberValidationError("totalSize", 1)) + }); + + it("returns an error when trying to create an availability without duration", async () => { + const response = await marketplace.createAvailability({ + totalSize: 3000, + maxCollateral: 1, + minPrice: 100, + } as any) + + assert.deepStrictEqual(response, missingNumberValidationError("duration")); + }); + + it("returns an error when trying to create an availability with zero duration", async () => { + const response = await marketplace.createAvailability({ + duration: 0, + maxCollateral: 1, + minPrice: 100, + totalSize: 3000 + }) + + assert.deepStrictEqual(response, minNumberValidationError("duration", 1)) + }); + + it("returns an error when trying to create an availability without min price", async () => { + const response = await marketplace.createAvailability({ + totalSize: 3000, + maxCollateral: 1, + duration: 100, + } as any) + + assert.deepStrictEqual(response, missingNumberValidationError("minPrice")); + }); + + it("returns an error when trying to create an availability without max collateral", async () => { + const response = await marketplace.createAvailability({ + totalSize: 3000, + minPrice: 100, + duration: 100, + } as any) + + assert.deepStrictEqual(response, missingNumberValidationError("maxCollateral")); + }); + + it("returns a response when the request succeed", async (t) => { + const data = { ...createAvailability(), freeSize: 1000 } + + t.mock.method(Fetch, "safe", () => + Promise.resolve({ error: false, data }), + ); + + const response = await marketplace.createAvailability({ + maxCollateral: 1, + totalSize: 3000, + minPrice: 100, + duration: 100, + hello: "world" + } as any) + + assert.deepStrictEqual(response, { error: false, data }); + }); + + it("returns a response when the create availability succeed", async (t) => { + const data = { ...createAvailability(), freeSize: 1000 } + + t.mock.method(Fetch, "safe", () => + Promise.resolve({ error: false, data }), + ); + + const response = await marketplace.createAvailability({ + maxCollateral: 1, + totalSize: 3000, + minPrice: 100, + duration: 100, + }) + + assert.deepStrictEqual(response, { error: false, data }); + }); + + it("returns an error when trying to update an availability without id", async () => { + const response = await marketplace.updateAvailability({ + } as any) + + assert.deepStrictEqual(response, missingStringValidationError("id")); + }); + + it("returns an error when trying to update an availability with zero total size", async () => { + const response = await marketplace.updateAvailability({ + id: faker.string.alphanumeric(64), + totalSize: 0 + }) + + assert.deepStrictEqual(response, minNumberValidationError("totalSize", 1)) + }); + + it("returns an error when trying to update an availability with zero duration", async () => { + const response = await marketplace.updateAvailability({ + id: faker.string.alphanumeric(64), + duration: 0 + }) + + assert.deepStrictEqual(response, minNumberValidationError("duration", 1)) + }); + + it("returns a response when the update availability succeed", async (t) => { + const data = createAvailability() + + t.mock.method(Fetch, "safe", () => + Promise.resolve({ error: false, data }), + ); + + const response = await marketplace.updateAvailability({ + id: faker.string.alphanumeric(64), + totalSize: 3000, + }) + + assert.deepStrictEqual(response, { error: false, data }); + }); + + it("returns an error when trying to create a storage request without cid", async () => { + const { cid, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest(rest as any) + + assert.deepStrictEqual(response, missingStringValidationError("cid")); + }); + + it("returns an error when trying to create a storage request without duration", async () => { + const { duration, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest(rest as any) + + assert.deepStrictEqual(response, missingNumberValidationError("duration")); + }); + + it("returns an error when trying to create a storage request with zero duration", async () => { + const { duration, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest({ ...rest, duration: 0 }) + + assert.deepStrictEqual(response, minNumberValidationError("duration", 1)); + }); + + it("returns an error when trying to create a storage request without reward", async () => { + const { reward, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest(rest as any) + + assert.deepStrictEqual(response, missingNumberValidationError("reward")); + }); + + it("returns an error when trying to create a storage request without proof probability", async () => { + const { proofProbability, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest(rest as any) + + assert.deepStrictEqual(response, missingNumberValidationError("proofProbability")); + }); + + it("returns an error when trying to create a storage request without expiry", async () => { + const { expiry, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest(rest as any) + + assert.deepStrictEqual(response, missingNumberValidationError("expiry")); + }); + + it("returns an error when trying to create a storage request with zero expiry", async () => { + const { expiry, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest({ ...rest, expiry: 0 }) + + assert.deepStrictEqual(response, minNumberValidationError("expiry", 1)); + }); + + it("returns an error when trying to create a storage request without collateral", async () => { + const { collateral, ...rest } = createStorageRequest() + + const response = await marketplace.createStorageRequest(rest as any) + + assert.deepStrictEqual(response, missingNumberValidationError("collateral")); + }); +}); diff --git a/src/marketplace/marketplace.ts b/src/marketplace/marketplace.ts new file mode 100644 index 0000000..1c98932 --- /dev/null +++ b/src/marketplace/marketplace.ts @@ -0,0 +1,173 @@ +import { Value } from '@sinclair/typebox/value' +import { Api } from "../api/config" +import { CodexValidationErrors } from '../errors/errors' +import { Fetch } from "../fetch-safe/fetch-safe" +import { SafeValue } from "../values/values" +import { CodexAvailability, CodexAvailabilityCreateResponse, CodexCreateAvailabilityInput, CodexCreateStorageRequestInput, CodexCreateStorageRequestResponse, CodexPurchase, CodexReservation, CodexSlot, CodexUpdateAvailabilityInput } from "./types" + +export class Marketplace { + readonly url: string + + constructor(url: string) { + this.url = url + } + + /** + * Returns active slots + */ + async activeSlots(): Promise> { + const url = this.url + Api.config.prefix + "/sales/slots" + + return Fetch.safe(url, { + method: "GET" + }) + } + + /** + * Returns active slot with id {slotId} for the host + */ + async activeSlot(slotId: string): Promise> { + const url = this.url + Api.config.prefix + "/sales/slots/" + slotId + + return Fetch.safe(url, { + method: "GET" + }) + } + + /** + * Returns storage that is for sale + */ + async availabilities(): Promise> { + const url = this.url + Api.config.prefix + "/sales/availability" + + return Fetch.safe(url, { + method: "GET" + }) + } + + /** + * Offers storage for sale + */ + async createAvailability(input: CodexCreateAvailabilityInput) + : Promise> { + const cleaned = Value.Clean(CodexCreateAvailabilityInput, input) + + if (!Value.Check(CodexCreateAvailabilityInput, cleaned)) { + const iterator = Value.Errors(CodexCreateAvailabilityInput, cleaned) + + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: CodexValidationErrors.map(iterator) + } + } + } + + const url = this.url + Api.config.prefix + "/sales/availability" + + return Fetch.safe(url, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify(cleaned) + }) + } + + /** + * The new parameters will be only considered for new requests. + * Existing Requests linked to this Availability will continue as is. + */ + async updateAvailability(input: CodexUpdateAvailabilityInput): Promise> { + const cleaned = Value.Clean(CodexUpdateAvailabilityInput, input) + + if (!Value.Check(CodexUpdateAvailabilityInput, cleaned)) { + const iterator = Value.Errors(CodexUpdateAvailabilityInput, cleaned) + + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: CodexValidationErrors.map(iterator) + } + } + } + + const url = this.url + Api.config.prefix + "/sales/availability/" + cleaned.id + + return Fetch.safe(url, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify(cleaned) + }) + } + + /** + * Return's list of Reservations for ongoing Storage Requests that the node hosts. + */ + async reservations(availabilityId: string): Promise> { + const url = this.url + Api.config.prefix + `/sales/availability/${availabilityId}/reservations` + + return Fetch.safe(url, { + method: "GET" + }) + } + + /** + * Returns list of purchase IDs + */ + async purchaseIds(): Promise> { + const url = this.url + Api.config.prefix + `/storage/purchases` + + return Fetch.safe(url, { + method: "GET" + }) + } + + /** + * Returns purchase details + */ + async purchaseDetail(purchaseId: string): Promise> { + const url = this.url + Api.config.prefix + `/storage/purchases/` + purchaseId + + return Fetch.safe(url, { + method: "GET" + }) + } + + /** + * Creates a new request for storage. + */ + async createStorageRequest(input: CodexCreateStorageRequestInput): Promise> { + const cleaned = Value.Clean(CodexCreateStorageRequestInput, input) + + if (!Value.Check(CodexCreateStorageRequestInput, cleaned)) { + const iterator = Value.Errors(CodexCreateStorageRequestInput, cleaned) + + return { + error: true, + data: { + type: "validation", + message: "Cannot validate the input", + errors: CodexValidationErrors.map(iterator) + } + } + } + + const { cid, ...body } = cleaned + const url = this.url + Api.config.prefix + "/storage/request/" + cid + + return Fetch.safe(url, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify(body) + }) + } +} diff --git a/src/marketplace/types.ts b/src/marketplace/types.ts new file mode 100644 index 0000000..a9a9957 --- /dev/null +++ b/src/marketplace/types.ts @@ -0,0 +1,195 @@ +import { Static, Type } from "@sinclair/typebox" + +export type CodexStorageRequest = { + id: string + + /** + * Address of Ethereum address + */ + client: string + + ask: { + /** + * Number of slots that the tequest want to have the content spread over. + */ + slots: number + + /** + * Amount of storage per slot (in bytes) as decimal string. + */ + slotSize: string + + /** + * The duration of the storage request in seconds. + */ + duration: string + + /** + * How often storage proofs are required as decimal string (in periods). + */ + proofProbability: string + + /** + * The maximum amount of tokens paid per second per slot to hosts + * the client is willing to pay. + */ + reward: string + + /** + * Max slots that can be lost without data considered to be lost. + */ + maxSlotLoss: number + }, + + content: { + /** + * Unique Content ID + */ + cid: string + + /** + * Erasure code parameters + */ + erasure: { + /** + * Total number of chunks generated by the erasure code process. + */ + totalChunks: number + }, + + /** + * Parameters for Proof of Retrievability + */ + por: { + u: string + publicKey: string + name: string + } + }, + + /* Number as decimal string that represents expiry threshold in seconds from when the Request is submitted. + * When the threshold is reached and the Request does not find requested amount of nodes to host the data, the Request is voided. + * The number of seconds can not be higher then the Request's duration itself. + */ + expiry: string, + + /** + * Random data + */ + nonce: string +} + +/** + * A storage slot is a portion of a storage contract that needs to be fulfilled + * by a host. To initiate a contract, all the slots must be filled. + */ +export type CodexSlot = { + id: string + + request: CodexStorageRequest, + + /** + * The slot index as hexadecimal string + */ + slotIndex: "string" +} + +/** + * Storage availability for sell. + */ +export type CodexAvailability = { + id: string + + /** + * Size of available storage in bytes + */ + totalSize: string + + /** + * Maximum time the storage should be sold for (in seconds) + */ + duration: string + + /** + * Minimum price to be paid (in amount of tokens) + */ + minPrice: string + + /** + * Maximum collateral user is willing to pay per filled Slot (in amount of tokens) + */ + maxCollateral: string +} + +export type CodexAvailabilityCreateResponse = CodexAvailability & { + id: string + + /** + * Unused size of availability's storage in bytes as decimal string + */ + freeSize: string +} + +export const CodexCreateAvailabilityInput = Type.Object({ + totalSize: Type.Number({ minimum: 1 }), + duration: Type.Number({ minimum: 1 }), + minPrice: Type.Number(), + maxCollateral: Type.Number() +}) + +export type CodexCreateAvailabilityInput = Static + +export const CodexUpdateAvailabilityInput = Type.Object({ + id: Type.String(), + totalSize: Type.Optional(Type.Number({ minimum: 1 })), + duration: Type.Optional(Type.Number({ minimum: 1 })), + minPrice: Type.Optional(Type.Number()), + maxCollateral: Type.Optional(Type.Number()) +}) + +export type CodexUpdateAvailabilityInput = Static + +export type CodexReservation = { + id: string + availabilityId: string + requestId: string + + /** + * Size in bytes + */ + size: string + + /** + * Slot Index as hexadecimal string + */ + slotIndex: string +} + +export type CodexPurchase = { + /** + * Description of the request's state + */ + state: string + + /** + * If request failed, then here is presented the error message + */ + error: string + + request: CodexStorageRequest +} + +export const CodexCreateStorageRequestInput = Type.Object({ + cid: Type.String(), + duration: Type.Number({ minimum: 1 }), + reward: Type.Number(), + proofProbability: Type.Number(), + nodes: Type.Optional(Type.Number({ default: 1 })), + tolerance: Type.Optional(Type.Number({ default: 0 })), + expiry: Type.Number({ minimum: 1 }), + collateral: Type.Number() +}) + +export type CodexCreateStorageRequestInput = Static + +export type CodexCreateStorageRequestResponse = Omit \ No newline at end of file diff --git a/src/values/values.ts b/src/values/values.ts new file mode 100644 index 0000000..f174097 --- /dev/null +++ b/src/values/values.ts @@ -0,0 +1,10 @@ +import { CodexError } from "../errors/errors"; + +/** + * SafeValue is a type used for error handling instead of throwing errors. + * It is inspired by Go's "error as value" concept. + * If the value represents an error, `error` is true and `data` will contain the error. + * If the value is not an error, `error` is false and `data` will contain the requested data. + */ +export type SafeValue = { error: false, data: T } | { error: true, data: CodexError } + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c950c29 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/strictest/tsconfig.json", + "include": [ + "src" + ], + "compilerOptions": { + "declaration": true, + "removeComments": false, + "lib": [ + "ES2020", + "DOM", + ], + "outDir": "./dist", + } +} \ No newline at end of file