diff --git a/.github/workflows/npx-test.yaml b/.github/workflows/npx-test.yaml deleted file mode 100644 index b1cb23b1..00000000 --- a/.github/workflows/npx-test.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: Dojo npx create - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v2 - with: - node-version: "20" - - - name: Install Dojo CLI - run: npm i @dojoengine/create-dojo -g - - - name: Create Dojo Project - run: npx @dojoengine/create-dojo diff --git a/examples/example-vite-react-sdk/package.json b/examples/example-vite-react-sdk/package.json index 1127bbd6..b5a5d9ed 100644 --- a/examples/example-vite-react-sdk/package.json +++ b/examples/example-vite-react-sdk/package.json @@ -10,13 +10,19 @@ "preview": "vite preview" }, "dependencies": { - "@dojoengine/core": "1.0.0-alpha.12", + "@dojoengine/core": "workspace:*", + "@dojoengine/create-burner": "workspace:*", "@dojoengine/sdk": "workspace:*", - "@dojoengine/torii-wasm": "1.0.0-alpha.12", + "@dojoengine/torii-wasm": "workspace:*", + "@types/uuid": "^10.0.0", + "immer": "^10.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "starknet": "6.11.0", + "uuid": "^10.0.0", "vite-plugin-top-level-await": "^1.4.4", - "vite-plugin-wasm": "^3.3.0" + "vite-plugin-wasm": "^3.3.0", + "zustand": "^4.5.5" }, "devDependencies": { "@eslint/js": "^9.11.1", diff --git a/examples/example-vite-react-sdk/src/App.tsx b/examples/example-vite-react-sdk/src/App.tsx index 158d864d..7d2db059 100644 --- a/examples/example-vite-react-sdk/src/App.tsx +++ b/examples/example-vite-react-sdk/src/App.tsx @@ -1,10 +1,15 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import "./App.css"; -import { ParsedEntity, SDK } from "@dojoengine/sdk"; +import { SDK, createDojoStore } from "@dojoengine/sdk"; import { Schema } from "./bindings.ts"; +import { v4 as uuidv4 } from "uuid"; + +export const useDojoStore = createDojoStore(); + function App({ db }: { db: SDK }) { - const [entities, setEntities] = useState[]>([]); + const state = useDojoStore((state) => state); + const entities = useDojoStore((state) => state.entities); useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -28,15 +33,7 @@ function App({ db }: { db: SDK }) { response.data && response.data[0].entityId !== "0x0" ) { - console.log(response.data); - setEntities((prevEntities) => { - return prevEntities.map((entity) => { - const newEntity = response.data?.find( - (e) => e.entityId === entity.entityId - ); - return newEntity ? newEntity : entity; - }); - }); + state.setEntities(response.data); } }, { logging: true } @@ -54,8 +51,6 @@ function App({ db }: { db: SDK }) { }; }, [db]); - console.log("entities:"); - useEffect(() => { const fetchEntities = async () => { try { @@ -76,23 +71,7 @@ function App({ db }: { db: SDK }) { return; } if (resp.data) { - console.log(resp.data); - setEntities((prevEntities) => { - const updatedEntities = [...prevEntities]; - resp.data?.forEach((newEntity) => { - const index = updatedEntities.findIndex( - (entity) => - entity.entityId === - newEntity.entityId - ); - if (index !== -1) { - updatedEntities[index] = newEntity; - } else { - updatedEntities.push(newEntity); - } - }); - return updatedEntities; - }); + state.setEntities(resp.data); } } ); @@ -104,20 +83,55 @@ function App({ db }: { db: SDK }) { fetchEntities(); }, [db]); + const optimisticUpdate = async () => { + const entityId = + "0x571368d35c8fe136adf81eecf96a72859c43de7efd8fdd3d6f0d17e308df984"; + + const transactionId = uuidv4(); + + state.applyOptimisticUpdate(transactionId, (draft) => { + draft.entities[entityId].models.dojo_starter.Moves!.remaining = 10; + }); + + try { + // Wait for the entity to be updated before full resolving the transaction. Reverts if the condition is not met. + const updatedEntity = await state.waitForEntityChange( + entityId, + (entity) => { + // Define your specific condition here + return entity?.models.dojo_starter.Moves?.can_move === true; + } + ); + + console.log("Entity has been updated to active:", updatedEntity); + + console.log("Updating entities..."); + } catch (error) { + console.error("Error updating entities:", error); + state.revertOptimisticUpdate(transactionId); + } finally { + console.log("Updating entities..."); + state.confirmTransaction(transactionId); + } + }; + return (

Game State

- {entities.map((entity) => ( -
-

Entity {entity.entityId}

+ + {Object.entries(entities).map(([entityId, entity]) => ( +
+

Entity {entityId}

Position

Player:{" "} {entity.models.dojo_starter.Position?.player ?? "N/A"}
- X: {entity.models.dojo_starter.Position?.vec.x ?? "N/A"} + X:{" "} + {entity.models.dojo_starter.Position?.vec?.x ?? "N/A"}
- Y: {entity.models.dojo_starter.Position?.vec.y ?? "N/A"} + Y:{" "} + {entity.models.dojo_starter.Position?.vec?.y ?? "N/A"}

Moves

diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 4f3554c2..623f4983 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -22,21 +22,25 @@ "./package.json": "./package.json" }, "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.0", "@vitest/coverage-v8": "^1.6.0", "eslint": "^8.57.1", "prettier": "^2.8.8", "tsup": "^8.3.0", "typescript": "^5.6.2", "vite": "^3.2.11", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "benchmark": "^2.1.4", + "lodash": "^4.17.21" }, "peerDependencies": { "starknet": "6.11.0" }, "dependencies": { + "@dojoengine/create-burner": "workspace:*", "@dojoengine/torii-client": "workspace:*", "axios": "^0.27.2", - "lodash": "^4.17.21", + "immer": "^10.1.1", "vite-plugin-wasm": "^3.3.0", "zustand": "^4.5.5" }, diff --git a/packages/sdk/src/__example__/index.ts b/packages/sdk/src/__example__/index.ts index b39f64ca..463a02f1 100644 --- a/packages/sdk/src/__example__/index.ts +++ b/packages/sdk/src/__example__/index.ts @@ -23,6 +23,12 @@ export interface ItemModel { durability: number; } +export interface GalaxyModel { + fieldOrder: string[]; + id: string; + name: string; +} + export interface MockSchemaType extends SchemaType { world: { player: PlayerModel; @@ -30,11 +36,7 @@ export interface MockSchemaType extends SchemaType { item: ItemModel; }; universe: { - galaxy: { - fieldOrder: string[]; - id: string; - name: string; - }; + galaxy: GalaxyModel; }; } diff --git a/packages/sdk/src/__tests__/state.test.ts b/packages/sdk/src/__tests__/state.test.ts new file mode 100644 index 00000000..ffb85fac --- /dev/null +++ b/packages/sdk/src/__tests__/state.test.ts @@ -0,0 +1,400 @@ +import { createDojoStore } from "../state/zustand"; +import { ParsedEntity } from "../types"; +import { describe, expect, beforeEach, test, vi } from "vitest"; +import { + MockSchemaType, + PlayerModel, + GameModel, + ItemModel, + GalaxyModel, +} from "../__example__/index"; + +interface MockParsedEntity extends ParsedEntity { + entityId: string; + models: { + world: { + player?: Partial; + game?: Partial; + item?: Partial; + }; + universe: { + galaxy?: Partial; + }; + }; +} + +describe("createDojoStore", () => { + let useStore: ReturnType>; + let initialPlayer: MockParsedEntity; + let initialGame: MockParsedEntity; + let initialItem: MockParsedEntity; + let initialGalaxy: MockParsedEntity; + + beforeEach(() => { + useStore = createDojoStore(); + initialPlayer = { + entityId: "player1", + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: "player1", + name: "Alice", + score: 100, + }, + }, + universe: {}, + }, + }; + initialGame = { + entityId: "game1", + models: { + world: { + game: { + fieldOrder: ["id", "status"], + id: "game1", + status: "active", + }, + }, + universe: {}, + }, + }; + initialItem = { + entityId: "item1", + models: { + world: { + item: { + fieldOrder: ["id", "type", "durability"], + id: "item1", + type: "sword", + durability: 50, + }, + }, + universe: {}, + }, + }; + initialGalaxy = { + entityId: "galaxy1", + models: { + world: {}, + universe: { + galaxy: { + fieldOrder: ["id", "name"], + id: "galaxy1", + name: "Milky Way", + }, + }, + }, + }; + }); + + test("should initialize with empty entities and pendingTransactions", () => { + const state = useStore.getState(); + expect(state.entities).toEqual({}); + expect(state.pendingTransactions).toEqual({}); + }); + + test("setEntities should add entities to the store", () => { + useStore + .getState() + .setEntities([ + initialPlayer, + initialGame, + initialItem, + initialGalaxy, + ]); + const state = useStore.getState(); + expect(state.entities["player1"]).toEqual(initialPlayer); + expect(state.entities["game1"]).toEqual(initialGame); + expect(state.entities["item1"]).toEqual(initialItem); + expect(state.entities["galaxy1"]).toEqual(initialGalaxy); + }); + + test("updateEntity should update an existing entity", () => { + useStore.getState().setEntities([initialPlayer]); + useStore.getState().updateEntity({ + entityId: "player1", + models: { + world: { + player: { + name: "Bob", + }, + }, + universe: {}, + }, + }); + const state = useStore.getState(); + expect(state.entities["player1"].models.world?.player?.name).toEqual( + "Bob" + ); + }); + + test("updateEntity should not add a new entity if entityId does not exist", () => { + useStore.getState().updateEntity({ + entityId: "nonexistent", + models: { + world: { + player: { + name: "Charlie", + }, + }, + universe: {}, + }, + }); + const state = useStore.getState(); + expect(state.entities["nonexistent"]).toBeUndefined(); + }); + + test("applyOptimisticUpdate should apply updates and add to pendingTransactions", () => { + useStore.getState().setEntities([initialItem]); + + const updateFn = (draft: any) => { + draft.entities["item1"].models.world!.item!.durability = 30; + }; + + useStore.getState().applyOptimisticUpdate("txn1", updateFn); + + const state = useStore.getState(); + expect(state.entities["item1"].models.world?.item?.durability).toEqual( + 30 + ); + expect(state.pendingTransactions["txn1"]).toBeDefined(); + expect(state.pendingTransactions["txn1"].transactionId).toBe("txn1"); + }); + + test("revertOptimisticUpdate should revert changes using inverse patches", () => { + useStore.getState().setEntities([initialItem]); + + const updateFn = (draft: any) => { + draft.entities["item1"].models.world!.item!.durability = 30; + }; + + useStore.getState().applyOptimisticUpdate("txn1", updateFn); + // Revert the optimistic update + useStore.getState().revertOptimisticUpdate("txn1"); + + const state = useStore.getState(); + expect(state.entities["item1"].models.world?.item?.durability).toEqual( + 50 + ); + expect(state.pendingTransactions["txn1"]).toBeUndefined(); + }); + + test("confirmTransaction should remove the transaction from pendingTransactions", () => { + useStore.getState().setEntities([initialItem]); + + const updateFn = (draft: any) => { + draft.entities["item1"].models.world!.item!.durability = 30; + }; + + useStore.getState().applyOptimisticUpdate("txn1", updateFn); + // Confirm the transaction + useStore.getState().confirmTransaction("txn1"); + + const state = useStore.getState(); + expect(state.entities["item1"].models.world?.item?.durability).toEqual( + 30 + ); + expect(state.pendingTransactions["txn1"]).toBeUndefined(); + }); + test("subscribeToEntity should call listener on entity updates", () => { + const listener = vi.fn(); + const unsubscribe = useStore + .getState() + .subscribeToEntity("player1", listener); + + // Update entity + useStore.getState().setEntities([initialPlayer]); + + expect(listener).toHaveBeenCalledWith(initialPlayer); + + // Update entity again + const updatedPlayer = { + ...initialPlayer, + models: { + world: { + player: { + ...initialPlayer.models.world!.player!, + name: "Charlie", + }, + }, + universe: {}, // Add the required universe property + }, + }; + useStore.getState().updateEntity(updatedPlayer); + expect(listener).toHaveBeenCalledWith(updatedPlayer); + + unsubscribe(); + // Further updates should not call listener + useStore.getState().updateEntity({ + entityId: "player1", + models: { + world: { + player: { + name: "Dave", + }, + }, + universe: {}, + }, + }); + expect(listener).toHaveBeenCalledTimes(2); + }); + test("waitForEntityChange should resolve when predicate is met", async () => { + useStore.getState().setEntities([initialGame]); + + const promise = useStore + .getState() + .waitForEntityChange( + "game1", + (entity) => entity?.models.world?.game?.status === "completed", + 1000 + ); + + // Simulate async update + setTimeout(() => { + useStore.getState().updateEntity({ + entityId: "game1", + models: { + world: { + game: { + status: "completed", + }, + }, + universe: {}, + }, + }); + }, 100); + + const result = await promise; + expect(result?.models.world?.game?.status).toBe("completed"); + }); + + test("waitForEntityChange should reject on timeout", async () => { + useStore.getState().setEntities([initialGame]); + + const promise = useStore + .getState() + .waitForEntityChange( + "game1", + (entity) => entity?.models.world?.game?.status === "never", + 500 + ); + + // Do not update the entity to meet predicate + + await expect(promise).rejects.toThrow( + "waitForEntityChange: Timeout of 500ms exceeded" + ); + }); + + test("getEntity should return the correct entity", () => { + useStore.getState().setEntities([initialGalaxy]); + const entity = useStore.getState().getEntity("galaxy1"); + expect(entity).toEqual(initialGalaxy); + }); + + test("getEntities should return all entities", () => { + const player2: MockParsedEntity = { + entityId: "player2", + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: "player2", + name: "Bob", + score: 80, + }, + }, + universe: {}, + }, + }; + useStore + .getState() + .setEntities([ + initialPlayer, + initialGame, + initialItem, + initialGalaxy, + player2, + ]); + const entities = useStore.getState().getEntities(); + expect(entities).toHaveLength(5); + expect(entities).toContainEqual(initialPlayer); + expect(entities).toContainEqual(initialGame); + expect(entities).toContainEqual(initialItem); + expect(entities).toContainEqual(initialGalaxy); + expect(entities).toContainEqual(player2); + }); + + test("getEntities should apply the filter correctly", () => { + const item2: MockParsedEntity = { + entityId: "item2", + models: { + world: { + item: { + fieldOrder: ["id", "type", "durability"], + id: "item2", + type: "shield", + durability: 80, + }, + }, + universe: {}, + }, + }; + useStore.getState().setEntities([initialItem, item2]); + const filtered = useStore + .getState() + .getEntities( + (entity) => (entity.models.world?.item?.durability ?? 0) > 50 + ); + expect(filtered).toHaveLength(1); + expect(filtered[0]).toEqual(item2); + }); + + test("getEntitiesByModel should return entities matching the specified namespace and model", () => { + const player2: MockParsedEntity = { + entityId: "player2", + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: "player2", + name: "Bob", + score: 80, + }, + }, + universe: {}, + }, + }; + const galaxy2: MockParsedEntity = { + entityId: "galaxy2", + models: { + world: {}, + universe: { + galaxy: { + fieldOrder: ["id", "name"], + id: "galaxy2", + name: "Andromeda", + }, + }, + }, + }; + useStore + .getState() + .setEntities([initialPlayer, player2, initialGalaxy, galaxy2]); + + const resultWorldPlayer = useStore + .getState() + .getEntitiesByModel("world", "player"); + expect(resultWorldPlayer).toHaveLength(2); + expect(resultWorldPlayer).toContainEqual(initialPlayer); + expect(resultWorldPlayer).toContainEqual(player2); + + const resultUniverseGalaxy = useStore + .getState() + .getEntitiesByModel("universe", "galaxy"); + expect(resultUniverseGalaxy).toHaveLength(2); + expect(resultUniverseGalaxy).toContainEqual(initialGalaxy); + expect(resultUniverseGalaxy).toContainEqual(galaxy2); + }); +}); diff --git a/packages/sdk/src/__tests__/zustand.perf.test.ts b/packages/sdk/src/__tests__/zustand.perf.test.ts new file mode 100644 index 00000000..d86cb8e5 --- /dev/null +++ b/packages/sdk/src/__tests__/zustand.perf.test.ts @@ -0,0 +1,169 @@ +import { createDojoStore } from "../state/zustand"; +import { ParsedEntity, SchemaType } from "../types"; +import { describe, it, beforeEach, expect } from "vitest"; +import Benchmark from "benchmark"; +import { + schema, + MockSchemaType, + PlayerModel, + GameModel, + ItemModel, + GalaxyModel, +} from "../__example__/index"; + +interface MockParsedEntity extends ParsedEntity { + entityId: string; + models: { + world: { + player?: Partial; + game?: Partial; + item?: Partial; + }; + universe: { + galaxy?: Partial; + }; + }; +} + +describe("Zustand Store Performance Tests", () => { + let useStore: ReturnType>; + let mockEntities: MockParsedEntity[] = []; + + beforeEach(() => { + useStore = createDojoStore(); + mockEntities = []; // Reset the mockEntities array before each test + // Generate a large number of mock entities for testing + const numberOfEntities = 1000; + for (let i = 0; i < numberOfEntities; i++) { + mockEntities.push({ + entityId: `entity${i}`, + models: { + world: { + player: { + fieldOrder: ["id", "name", "score"], + id: `player${i}`, + name: `Player${i}`, + score: i, + }, + game: { + fieldOrder: ["id", "status"], + id: `game${i}`, + status: i % 2 === 0 ? "active" : "inactive", + }, + item: { + fieldOrder: ["id", "type", "durability"], + id: `item${i}`, + type: i % 3 === 0 ? "sword" : "shield", + durability: 100 - (i % 100), + }, + }, + universe: { + galaxy: { + fieldOrder: ["id", "name"], + id: `galaxy${i}`, + name: `Galaxy${i}`, + }, + }, + }, + }); + } + }); + + it("should benchmark setEntities performance", async () => { + const suite = new Benchmark.Suite(); + + suite + .add("setEntities", () => { + useStore.getState().setEntities(mockEntities); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + + // Optional: Assert that setEntities completes within a reasonable time + // Example: expect(setEntitiesTime).toBeLessThan(100); // in milliseconds + }); + + it("should benchmark updateEntity performance", async () => { + // First, set entities + useStore.getState().setEntities(mockEntities); + + const suite = new Benchmark.Suite(); + + suite + .add("updateEntity", () => { + useStore.getState().updateEntity({ + entityId: "entity500", + models: { + world: { + player: { + name: "UpdatedPlayer500", + score: 999, + }, + }, + universe: {}, + }, + }); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + }); + + it("should benchmark applyOptimisticUpdate performance", async () => { + // First, set entities + useStore.getState().setEntities(mockEntities); + + const suite = new Benchmark.Suite(); + + suite + .add("applyOptimisticUpdate", () => { + useStore + .getState() + .applyOptimisticUpdate("txn_perf", (draft) => { + draft.entities[ + "entity500" + ].models.world!.item!.durability = 75; + }); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + }); + + it("should benchmark revertOptimisticUpdate performance", async () => { + // First, set entities + useStore.getState().setEntities(mockEntities); + + // Apply an optimistic update + useStore.getState().applyOptimisticUpdate("txn_perf", (draft) => { + draft.entities["entity500"].models.world!.item!.durability = 75; + }); + + const suite = new Benchmark.Suite(); + + suite + .add("revertOptimisticUpdate", () => { + useStore.getState().revertOptimisticUpdate("txn_perf"); + }) + .on("cycle", (event: any) => { + console.log(String(event.target)); + }) + .on("complete", function () { + console.log("Fastest is " + this.filter("fastest").map("name")); + }) + .run({ async: false }); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6c3f033a..5165fddd 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -8,6 +8,7 @@ import { SchemaType, SDK, UnionOfModelData } from "./types"; import { Account, Signature, StarknetDomain, TypedData } from "starknet"; export * from "./types"; +export * from "./state"; interface SDKConfig { client: torii.ClientConfig; diff --git a/packages/sdk/src/state/index.ts b/packages/sdk/src/state/index.ts new file mode 100644 index 00000000..5524d86e --- /dev/null +++ b/packages/sdk/src/state/index.ts @@ -0,0 +1 @@ +export * from "./zustand"; diff --git a/packages/sdk/src/state/zustand.ts b/packages/sdk/src/state/zustand.ts new file mode 100644 index 00000000..522899a8 --- /dev/null +++ b/packages/sdk/src/state/zustand.ts @@ -0,0 +1,176 @@ +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { + Draft, + Patch, + WritableDraft, + applyPatches, + produceWithPatches, +} from "immer"; + +import { enablePatches } from "immer"; +import { subscribeWithSelector } from "zustand/middleware"; +import { ParsedEntity, SchemaType } from "../types"; + +enablePatches(); + +interface PendingTransaction { + transactionId: string; + patches: Patch[]; + inversePatches: Patch[]; +} + +interface GameState { + entities: Record>; + pendingTransactions: Record; + setEntities: (entities: ParsedEntity[]) => void; + updateEntity: (entity: Partial>) => void; + applyOptimisticUpdate: ( + transactionId: string, + updateFn: (draft: Draft>) => void + ) => void; + revertOptimisticUpdate: (transactionId: string) => void; + confirmTransaction: (transactionId: string) => void; + subscribeToEntity: ( + entityId: string, + listener: (entity: ParsedEntity | undefined) => void + ) => () => void; + waitForEntityChange: ( + entityId: string, + predicate: (entity: ParsedEntity | undefined) => boolean, + timeout?: number + ) => Promise | undefined>; + getEntity: (entityId: string) => ParsedEntity | undefined; + getEntities: ( + filter?: (entity: ParsedEntity) => boolean + ) => ParsedEntity[]; + getEntitiesByModel: ( + namespace: keyof T, + model: keyof T[keyof T] + ) => ParsedEntity[]; +} + +/** + * Factory function to create a Zustand store based on a given SchemaType. + * + * @template T - The schema type. + * @returns A Zustand hook tailored to the provided schema. + */ +export function createDojoStore() { + const useStore = create>()( + subscribeWithSelector( + immer((set, get) => ({ + entities: {}, + pendingTransactions: {}, + setEntities: (entities: ParsedEntity[]) => { + set((state: Draft>) => { + entities.forEach((entity) => { + state.entities[entity.entityId] = + entity as WritableDraft>; + }); + }); + }, + updateEntity: (entity: Partial>) => { + set((state: Draft>) => { + if ( + entity.entityId && + state.entities[entity.entityId] + ) { + Object.assign( + state.entities[entity.entityId], + entity + ); + } + }); + }, + applyOptimisticUpdate: (transactionId, updateFn) => { + const currentState = get(); + const [nextState, patches, inversePatches] = + produceWithPatches( + currentState, + (draftState: Draft>) => { + updateFn(draftState); + } + ); + + set(() => nextState); + + set((state: Draft>) => { + state.pendingTransactions[transactionId] = { + transactionId, + patches, + inversePatches, + }; + }); + }, + revertOptimisticUpdate: (transactionId) => { + const transaction = + get().pendingTransactions[transactionId]; + if (transaction) { + set((state: Draft>) => + applyPatches(state, transaction.inversePatches) + ); + set((state: Draft>) => { + delete state.pendingTransactions[transactionId]; + }); + } + }, + confirmTransaction: (transactionId) => { + set((state: Draft>) => { + delete state.pendingTransactions[transactionId]; + }); + }, + subscribeToEntity: (entityId, listener): (() => void) => { + return useStore.subscribe((state) => { + const entity = state.entities[entityId]; + listener(entity); + }); + }, + waitForEntityChange: (entityId, predicate, timeout = 6000) => { + return new Promise | undefined>( + (resolve, reject) => { + const unsubscribe = useStore.subscribe( + (state) => state.entities[entityId], + (entity) => { + if (predicate(entity)) { + clearTimeout(timer); + unsubscribe(); + resolve(entity); + } + } + ); + + const timer = setTimeout(() => { + unsubscribe(); + reject( + new Error( + `waitForEntityChange: Timeout of ${timeout}ms exceeded` + ) + ); + }, timeout); + } + ); + }, + // Implementing query layer methods + getEntity: (entityId: string) => { + return get().entities[entityId]; + }, + + getEntities: ( + filter?: (entity: ParsedEntity) => boolean + ) => { + const allEntities = Object.values(get().entities); + return filter ? allEntities.filter(filter) : allEntities; + }, + + getEntitiesByModel: (namespace, model) => { + return get().getEntities((entity) => { + return !!entity.models[namespace]?.[model]; + }); + }, + })) + ) + ); + + return useStore; +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 877ea35d..18385209 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -2,6 +2,7 @@ import * as torii from "@dojoengine/torii-client"; import { Account, StarknetDomain, TypedData } from "starknet"; +import { AccountInterface, RpcProvider } from "starknet"; /** * Utility type to ensure at least one property is present @@ -226,7 +227,9 @@ export type ParsedEntity = { entityId: string; models: { [K in keyof T]: { - [M in keyof T[K]]?: T[K][M]; + [M in keyof T[K]]?: T[K][M] extends object + ? Partial + : T[K][M]; }; }; }; @@ -337,3 +340,82 @@ export interface SDK { ) => TypedData; sendMessage: (data: TypedData, account: Account) => Promise; } + +export type BurnerStorage = { + [address: string]: BurnerRecord; +}; + +export type BurnerRecord = { + chainId: string; + privateKey: string; + publicKey: string; + deployTx: string; + masterAccount: string; + active: boolean; + accountIndex?: number; + metadata?: any; +}; + +export type Burner = { + address: string; + active: boolean; + masterAccount?: string; + accountIndex?: number; +}; + +export interface BurnerManagerOptions { + masterAccount: Account; + accountClassHash: string; + feeTokenAddress: string; + rpcProvider: RpcProvider; +} + +export interface BurnerAccount { + create: (options?: BurnerCreateOptions) => void; + list: () => Burner[]; + get: (address: string) => AccountInterface; + remove: (address: string) => void; + account: Account; + select: (address: string) => void; + deselect: () => void; + isDeploying: boolean; + clear: () => void; + count: number; + copyToClipboard: () => Promise; + applyFromClipboard: () => Promise; + getActiveAccount?: () => Account | null; + generateAddressFromSeed?: (options?: BurnerCreateOptions) => string; + checkIsDeployed: (address: string, deployTx?: string) => Promise; +} + +export interface BurnerCreateOptions { + secret?: string; + index?: number; + metadata?: any; + prefundedAmount?: string; + maxFee?: number; +} + +export interface BurnerKeys { + privateKey: string; + publicKey: string; + address: string; +} + +export type Predeployed = Burner & { name?: string }; + +export type PredeployedStorage = { + [address: string]: PredeployedAccount; +}; + +export interface PredeployedManagerOptions { + rpcProvider: RpcProvider; + predeployedAccounts: PredeployedAccount[]; +} + +export type PredeployedAccount = { + name?: string; + address: string; + privateKey: string; + active: boolean; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15638b5c..3d1d9d93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,7 +344,7 @@ importers: version: 3.3.0(vite@4.5.5(@types/node@20.16.6)(terser@5.33.0)) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@types/node': specifier: ^20.16.6 @@ -507,26 +507,44 @@ importers: examples/example-vite-react-sdk: dependencies: '@dojoengine/core': - specifier: 1.0.0-alpha.12 - version: 1.0.0-alpha.12(starknet@6.11.0(encoding@0.1.13))(typescript@5.6.2) + specifier: workspace:* + version: link:../../packages/core + '@dojoengine/create-burner': + specifier: workspace:* + version: link:../../packages/create-burner '@dojoengine/sdk': specifier: workspace:* version: link:../../packages/sdk '@dojoengine/torii-wasm': - specifier: 1.0.0-alpha.12 - version: 1.0.0-alpha.12 + specifier: workspace:* + version: link:../../packages/torii-wasm + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + immer: + specifier: ^10.1.1 + version: 10.1.1 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + starknet: + specifier: 6.11.0 + version: 6.11.0(encoding@0.1.13) + uuid: + specifier: ^10.0.0 + version: 10.0.0 vite-plugin-top-level-await: specifier: ^1.4.4 version: 1.4.4(rollup@4.22.4)(vite@5.4.7(@types/node@22.6.1)(terser@5.33.0)) vite-plugin-wasm: specifier: ^3.3.0 version: 3.3.0(vite@5.4.7(@types/node@22.6.1)(terser@5.33.0)) + zustand: + specifier: ^4.5.5 + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@eslint/js': specifier: ^9.11.1 @@ -614,7 +632,7 @@ importers: version: 1.1.0(@types/react@18.3.9)(react@18.3.1) '@react-three/drei': specifier: ^9.114.0 - version: 9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) + version: 9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) '@react-three/fiber': specifier: ^8.17.8 version: 8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1) @@ -695,7 +713,7 @@ importers: version: 3.3.0(vite@4.5.5(@types/node@20.16.6)(terser@5.33.0)) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@storybook/addon-essentials': specifier: ^7.6.20 @@ -1000,7 +1018,7 @@ importers: version: 2.19.0 zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: '@babel/core': specifier: ^7.25.2 @@ -1032,15 +1050,18 @@ importers: packages/sdk: dependencies: + '@dojoengine/create-burner': + specifier: workspace:* + version: link:../create-burner '@dojoengine/torii-client': specifier: workspace:* version: link:../torii-client axios: specifier: ^0.27.2 version: 0.27.2 - lodash: - specifier: ^4.17.21 - version: 4.17.21 + immer: + specifier: ^10.1.1 + version: 10.1.1 starknet: specifier: 6.11.0 version: 6.11.0(encoding@0.1.13) @@ -1049,14 +1070,23 @@ importers: version: 3.3.0(vite@3.2.11(@types/node@22.6.1)(terser@5.33.0)) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: + '@rollup/plugin-commonjs': + specifier: ^28.0.0 + version: 28.0.0(rollup@4.22.4) '@vitest/coverage-v8': specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0(@types/node@22.6.1)(jsdom@24.1.3)(terser@5.33.0)) + benchmark: + specifier: ^2.1.4 + version: 2.1.4 eslint: specifier: ^8.57.1 version: 8.57.1 + lodash: + specifier: ^4.17.21 + version: 4.17.21 prettier: specifier: ^2.8.8 version: 2.8.8 @@ -1092,7 +1122,7 @@ importers: version: 1.6.0(@types/node@22.6.1)(jsdom@24.1.3)(terser@5.33.0) zustand: specifier: ^4.5.5 - version: 4.5.5(@types/react@18.3.9)(react@18.3.1) + version: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) devDependencies: tsup: specifier: ^8.3.0 @@ -1995,18 +2025,9 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@dojoengine/core@1.0.0-alpha.12': - resolution: {integrity: sha512-KuinebMRPrsGebpQqW8oXVYRaCiUOdngjr4vN6WaWkUcyvFRfeJqzCemQdgqtOwvZZKjo6UIQNvYWPsuzjcxnA==} - hasBin: true - peerDependencies: - starknet: 6.11.0 - '@dojoengine/recs@2.0.13': resolution: {integrity: sha512-Cgz4Unlnk2FSDoFTYKrJexX/KiSYPMFMxftxQkC+9LUKS5yNGkgFQM7xu4/L1HvpDAenL7NjUmH6ynRAS7Iifw==} - '@dojoengine/torii-wasm@1.0.0-alpha.12': - resolution: {integrity: sha512-GiPlaJkSqjpCzN42xv6F0zv1UJLUcIthiwU8LQYU82DCVqKkODvd/ad0YH00PQ2pB/ILEiMvoJQUQXP108yFqQ==} - '@emnapi/core@1.2.0': resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==} @@ -3995,6 +4016,15 @@ packages: '@types/babel__core': optional: true + '@rollup/plugin-commonjs@28.0.0': + resolution: {integrity: sha512-BJcu+a+Mpq476DMXG+hevgPSl56bkUoi88dKT8t3RyUp8kGuOh+2bU8Gs7zXDlu+fyZggnJ+iOBGrb/O1SorYg==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-node-resolve@15.3.0': resolution: {integrity: sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==} engines: {node: '>=14.0.0'} @@ -4906,6 +4936,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -5582,6 +5615,9 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + benchmark@2.1.4: + resolution: {integrity: sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==} + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -7409,6 +7445,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.1.1: + resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + immutable@3.7.6: resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} engines: {node: '>=0.8.0'} @@ -7628,6 +7667,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -9301,6 +9343,9 @@ packages: pkg-types@1.2.0: resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -12609,16 +12654,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dojoengine/core@1.0.0-alpha.12(starknet@6.11.0(encoding@0.1.13))(typescript@5.6.2)': - dependencies: - '@dojoengine/recs': 2.0.13(typescript@5.6.2)(zod@3.23.8) - starknet: 6.11.0(encoding@0.1.13) - zod: 3.23.8 - transitivePeerDependencies: - - bufferutil - - typescript - - utf-8-validate - '@dojoengine/recs@2.0.13(typescript@5.6.2)(zod@3.23.8)': dependencies: '@latticexyz/schema-type': 2.0.12(typescript@5.6.2)(zod@3.23.8) @@ -12631,8 +12666,6 @@ snapshots: - utf-8-validate - zod - '@dojoengine/torii-wasm@1.0.0-alpha.12': {} - '@emnapi/core@1.2.0': dependencies: '@emnapi/wasi-threads': 1.0.1 @@ -14594,7 +14627,7 @@ snapshots: '@octokit/request-error': 3.0.3 '@octokit/types': 9.3.2 is-plain-object: 5.0.0 - node-fetch: 2.6.7(encoding@0.1.13) + node-fetch: 2.7.0(encoding@0.1.13) universal-user-agent: 6.0.1 transitivePeerDependencies: - encoding @@ -15062,7 +15095,7 @@ snapshots: '@react-spring/types@9.6.1': {} - '@react-three/drei@9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)': + '@react-three/drei@9.114.0(@react-three/fiber@8.17.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1))(@types/react@18.3.9)(@types/three@0.160.0)(immer@10.1.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.160.1)': dependencies: '@babel/runtime': 7.25.6 '@mediapipe/tasks-vision': 0.10.8 @@ -15086,7 +15119,7 @@ snapshots: three-mesh-bvh: 0.7.8(three@0.160.1) three-stdlib: 2.33.0(three@0.160.1) troika-three-text: 0.49.1(three@0.160.1) - tunnel-rat: 0.1.2(@types/react@18.3.9)(react@18.3.1) + tunnel-rat: 0.1.2(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) utility-types: 3.11.0 uuid: 9.0.1 zustand: 3.7.2(react@18.3.1) @@ -15129,6 +15162,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@rollup/plugin-commonjs@28.0.0(rollup@4.22.4)': + dependencies: + '@rollup/pluginutils': 5.1.2(rollup@4.22.4) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.3.0(picomatch@2.3.1) + is-reference: 1.2.1 + magic-string: 0.30.11 + picomatch: 2.3.1 + optionalDependencies: + rollup: 4.22.4 + '@rollup/plugin-node-resolve@15.3.0(rollup@2.79.1)': dependencies: '@rollup/pluginutils': 5.1.2(rollup@2.79.1) @@ -15295,12 +15340,12 @@ snapshots: '@scure/bip32@1.3.2': dependencies: '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.2 + '@noble/hashes': 1.3.3 '@scure/base': 1.1.9 '@scure/bip32@1.4.0': dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 @@ -15312,7 +15357,7 @@ snapshots: '@scure/bip39@1.2.1': dependencies: - '@noble/hashes': 1.3.2 + '@noble/hashes': 1.3.3 '@scure/base': 1.1.9 '@scure/bip39@1.4.0': @@ -16459,6 +16504,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/uuid@9.0.8': {} '@types/web@0.0.114': {} @@ -17336,6 +17383,11 @@ snapshots: before-after-hook@2.2.3: {} + benchmark@2.1.4: + dependencies: + lodash: 4.17.21 + platform: 1.3.6 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -18944,6 +18996,10 @@ snapshots: dependencies: pend: 1.2.0 + fdir@6.3.0(picomatch@2.3.1): + optionalDependencies: + picomatch: 2.3.1 + fdir@6.3.0(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -19524,6 +19580,8 @@ snapshots: immediate@3.0.6: {} + immer@10.1.1: {} + immutable@3.7.6: {} immutable@4.3.7: {} @@ -19721,6 +19779,10 @@ snapshots: is-promise@2.2.2: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.6 + is-regex@1.1.4: dependencies: call-bind: 1.0.7 @@ -21304,6 +21366,8 @@ snapshots: mlly: 1.7.1 pathe: 1.1.2 + platform@1.3.6: {} + polished@4.3.1: dependencies: '@babel/runtime': 7.25.6 @@ -22714,9 +22778,9 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tunnel-rat@0.1.2(@types/react@18.3.9)(react@18.3.1): + tunnel-rat@0.1.2(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1): dependencies: - zustand: 4.5.5(@types/react@18.3.9)(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1) transitivePeerDependencies: - '@types/react' - immer @@ -23385,8 +23449,8 @@ snapshots: webauthn-p256@0.0.5: dependencies: - '@noble/curves': 1.4.0 - '@noble/hashes': 1.4.0 + '@noble/curves': 1.6.0 + '@noble/hashes': 1.5.0 webcrypto-core@1.8.0: dependencies: @@ -23734,11 +23798,12 @@ snapshots: optionalDependencies: react: 18.3.1 - zustand@4.5.5(@types/react@18.3.9)(react@18.3.1): + zustand@4.5.5(@types/react@18.3.9)(immer@10.1.1)(react@18.3.1): dependencies: use-sync-external-store: 1.2.2(react@18.3.1) optionalDependencies: '@types/react': 18.3.9 + immer: 10.1.1 react: 18.3.1 zwitch@2.0.4: {}