diff --git a/.changeset/brave-kiwis-explode.md b/.changeset/brave-kiwis-explode.md new file mode 100644 index 00000000..7ea4f1b6 --- /dev/null +++ b/.changeset/brave-kiwis-explode.md @@ -0,0 +1,15 @@ +--- +"@dojoengine/utils": patch +"@dojoengine/sdk": patch +"@dojoengine/core": patch +"@dojoengine/create-burner": patch +"@dojoengine/create-dojo": patch +"@dojoengine/predeployed-connector": patch +"@dojoengine/react": patch +"@dojoengine/state": patch +"@dojoengine/torii-client": patch +"@dojoengine/torii-wasm": patch +"@dojoengine/utils-wasm": patch +--- + +fix: cairo option and enum ignore in zustand merge diff --git a/examples/example-vite-react-sdk/src/App.tsx b/examples/example-vite-react-sdk/src/App.tsx index 072ea224..cf3ef8e5 100644 --- a/examples/example-vite-react-sdk/src/App.tsx +++ b/examples/example-vite-react-sdk/src/App.tsx @@ -35,7 +35,11 @@ function App() { // Querying Moves and Position models that has at least [account.address] as key KeysClause( [ModelsMapping.Moves, ModelsMapping.Position], - [addAddressPadding(account?.address ?? "0")], + [ + account?.address + ? addAddressPadding(account.address) + : undefined, + ], "FixedLen" ).build() ) diff --git a/examples/example-vite-react-sdk/src/main.tsx b/examples/example-vite-react-sdk/src/main.tsx index 465ed4a6..39447f8a 100644 --- a/examples/example-vite-react-sdk/src/main.tsx +++ b/examples/example-vite-react-sdk/src/main.tsx @@ -22,8 +22,6 @@ import StarknetProvider from "./starknet-provider.tsx"; async function main() { const sdk = await init({ client: { - toriiUrl: dojoConfig.toriiUrl, - relayUrl: dojoConfig.relayUrl, worldAddress: dojoConfig.manifest.world.address, }, domain: { diff --git a/package.json b/package.json index 14165ced..e0bc22bd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "format:check": "turbo format:check", "release": "pnpm build && pnpm -F './packages/**' publish -r --force --no-git-checks", "release:dry-run": "pnpm -F './packages/**' publish -r --force --dry-run", - "dev": "turbo dev --concurrency 15" + "dev": "turbo watch dev --concurrency 15", + "docs": "turbo run docs --ui stream" }, "devDependencies": { "@commitlint/cli": "^18.6.1", diff --git a/packages/sdk/src/experimental/index.ts b/packages/sdk/src/experimental/index.ts index 00d640dc..a3cf3299 100644 --- a/packages/sdk/src/experimental/index.ts +++ b/packages/sdk/src/experimental/index.ts @@ -3,6 +3,7 @@ import { SchemaType, SDKConfig, StandardizedQueryResult } from "../types"; import { parseEntities } from "../parseEntities"; import { parseHistoricalEvents } from "../parseHistoricalEvents"; import { intoEntityKeysClause } from "../convertClauseToEntityKeysClause"; +import { defaultClientConfig } from ".."; export type ToriiSubscriptionCallback = (response: { data?: StandardizedQueryResult | StandardizedQueryResult[]; @@ -10,7 +11,12 @@ export type ToriiSubscriptionCallback = (response: { }) => void; export async function init(options: SDKConfig) { - const client = await torii.createClient(options.client); + const clientConfig = { + ...defaultClientConfig, + ...options.client, + } as torii.ClientConfig; + + const client = await torii.createClient(clientConfig); return { getEntities: async (query: torii.Query) => { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d2235dfd..6d361478 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,7 +1,7 @@ import * as torii from "@dojoengine/torii-client"; -import { Account, Signature, StarknetDomain, TypedData } from "starknet"; +import type { Account, Signature, StarknetDomain, TypedData } from "starknet"; -import { +import type { GetParams, SchemaType, SDK, @@ -14,7 +14,7 @@ import { import { intoEntityKeysClause } from "./convertClauseToEntityKeysClause"; import { parseEntities } from "./parseEntities"; import { parseHistoricalEvents } from "./parseHistoricalEvents"; -import { ToriiQueryBuilder } from "./toriiQueryBuilder"; +import type { ToriiQueryBuilder } from "./toriiQueryBuilder"; import { generateTypedData } from "./generateTypedData"; export * from "./types"; @@ -34,6 +34,11 @@ export async function createClient( return await torii.createClient(config); } +export const defaultClientConfig: Partial = { + toriiUrl: "http://localhost:8080", + relayUrl: "/ip4/127.0.0.1/tcp/9090", +}; + /** * Initializes the SDK with the provided configuration and schema. * @@ -43,7 +48,11 @@ export async function createClient( export async function init( options: SDKConfig ): Promise> { - const client = await createClient(options.client); + const clientConfig = { + ...defaultClientConfig, + ...options.client, + } as torii.ClientConfig; + const client = await createClient(clientConfig); return { client, @@ -137,6 +146,7 @@ export async function init( const parsedData = historical ? parseHistoricalEvents(data) : parseEntities(data); + callback({ data: parsedData as ToriiResponse< T, diff --git a/packages/sdk/src/parseEntities.ts b/packages/sdk/src/parseEntities.ts index 3f66a81c..79a8b18e 100644 --- a/packages/sdk/src/parseEntities.ts +++ b/packages/sdk/src/parseEntities.ts @@ -76,7 +76,8 @@ function parseValue(value: torii.Ty): any { CairoOptionVariant.Some, parseValue((value.value as torii.EnumValue).value) ); - } else if ("None" === (value.value as torii.EnumValue).option) { + } + if ("None" === (value.value as torii.EnumValue).option) { return new CairoOption(CairoOptionVariant.None); } diff --git a/packages/sdk/src/react/hooks.ts b/packages/sdk/src/react/hooks.ts index 0e7d75fe..87e25c83 100644 --- a/packages/sdk/src/react/hooks.ts +++ b/packages/sdk/src/react/hooks.ts @@ -63,6 +63,31 @@ export function useModel< return modelData; } +/** + * Custom hook to retrieve all entities that have a specific model. + * + * @param model - The model to retrieve, specified as a string in the format "namespace-modelName". + * @returns The model structure if found, otherwise undefined. + */ +export function useModels< + N extends keyof SchemaType, + M extends keyof SchemaType[N] & string, + Client extends (...args: any) => any, + Schema extends SchemaType +>(model: `${N}-${M}`): { [entityId: string]: SchemaType[N][M] | undefined } { + const [namespace, modelName] = model.split("-") as [N, M]; + const { useDojoStore } = + useContext>(DojoContext); + + const modelData = useDojoStore((state) => + state.getEntitiesByModel(namespace, modelName).map((entity) => ({ + [entity.entityId]: entity.models?.[namespace]?.[modelName], + })) + ) as unknown as { [entityId: string]: SchemaType[N][M] | undefined }; + + return modelData; +} + /** * Hook that exposes sdk features. * @@ -223,6 +248,7 @@ export function useEntityQuery( processInitialData: (data) => state.mergeEntities(data), processUpdateData: (data) => { const entity = data.pop(); + if (entity && entity.entityId !== "0x0") { state.updateEntity(entity); } diff --git a/packages/sdk/src/state/zustand.ts b/packages/sdk/src/state/zustand.ts index b8c654ca..db1bb994 100644 --- a/packages/sdk/src/state/zustand.ts +++ b/packages/sdk/src/state/zustand.ts @@ -6,6 +6,7 @@ import { enablePatches } from "immer"; import { subscribeWithSelector } from "zustand/middleware"; import { ParsedEntity, SchemaType } from "../types"; import { GameState } from "."; +import { CairoCustomEnum, CairoOption, CairoOptionVariant } from "starknet"; enablePatches(); @@ -20,6 +21,122 @@ type CreateStore = { ): StoreApi; }; +/** + * Check if a value is a CairoOption + * @param value - The value to check + * @returns True if the value is a CairoOption, false otherwise + */ +function isCairoOption(value: unknown): value is CairoOption { + return value instanceof CairoOption; +} + +/** + * Merge two CairoOption instances + * @param target - The target CairoOption + * @param source - The source CairoOption + * @returns A new CairoOption instance with the merged value + */ +function mergeCairoOption( + target: MergedModels, + source: Partial> +): MergedModels { + // If source is Some, prefer source's value + if (source instanceof CairoOption && source.isSome()) { + return new CairoOption( + CairoOptionVariant.Some, + source.unwrap() + ) as unknown as MergedModels; + } + + // If source is None or undefined, keep target + if (target instanceof CairoOption) { + if (target.isSome()) { + return new CairoOption( + CairoOptionVariant.Some, + target.unwrap() + ) as unknown as MergedModels; + } + return new CairoOption( + CairoOptionVariant.None + ) as unknown as MergedModels; + } + + // This should not happen if both are CairoOption instances + return target as unknown as MergedModels; +} + +/** + * Check if a value is a CairoCustomEnum + * @param value - The value to check + * @returns True if the value is a CairoCustomEnum, false otherwise + */ +function isCairoCustomEnum(value: unknown): value is CairoCustomEnum { + return value instanceof CairoCustomEnum; +} + +/** + * Merge two CairoCustomEnum instances + * @param target - The target CairoCustomEnum + * @param source - The source CairoCustomEnum + * @returns A new CairoCustomEnum instance with the merged value + */ +function mergeCairoCustomEnum( + target: MergedModels, + source: Partial> +): MergedModels { + if (!isCairoCustomEnum(target) || !isCairoCustomEnum(source)) { + return target; + } + // If source has an active variant, prefer it + const sourceActiveVariant = source.activeVariant(); + const sourceValue = source.unwrap(); + + if (sourceActiveVariant && sourceValue !== undefined) { + // Create a new enum with source's active variant + const newEnumContent: Record = {}; + + // Initialize all variants from target with undefined + for (const key in target.variant) { + newEnumContent[key] = undefined; + } + + // Set the active variant from source + newEnumContent[sourceActiveVariant] = sourceValue; + + return new CairoCustomEnum( + newEnumContent + ) as unknown as MergedModels; + } + + // If source doesn't have an active variant, keep target + const targetActiveVariant = target.activeVariant(); + const targetValue = target.unwrap(); + + if (targetActiveVariant && targetValue !== undefined) { + const newEnumContent: Record = {}; + + // Initialize all variants with undefined + for (const key in target.variant) { + newEnumContent[key] = undefined; + } + + // Set the active variant from target + newEnumContent[targetActiveVariant] = targetValue; + + return new CairoCustomEnum( + newEnumContent + ) as unknown as MergedModels; + } + + // Fallback if not both CairoCustomEnum + return target; +} + +/** + * Merged models type + * @template T - The schema type + * @returns The merged models type + */ type MergedModels = ParsedEntity["models"][keyof ParsedEntity["models"]]; @@ -27,8 +144,13 @@ function deepMerge( target: MergedModels, source: Partial> ): MergedModels { + if (isCairoOption(target) && isCairoOption(source)) { + return mergeCairoOption(target, source); + } + if (isCairoCustomEnum(target) && isCairoCustomEnum(source)) { + return mergeCairoCustomEnum(target, source); + } const result = { ...target } as Record; - for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { if ( @@ -72,15 +194,15 @@ export function createDojoStoreFactory( pendingTransactions: {}, setEntities: (entities: ParsedEntity[]) => { set((state: Draft>) => { - entities.forEach((entity) => { + for (const entity of entities) { state.entities[entity.entityId] = entity as WritableDraft>; - }); + } }); }, mergeEntities: (entities: ParsedEntity[]) => { set((state: Draft>) => { - entities.forEach((entity) => { + for (const entity of entities) { if (entity.entityId && entity.models) { const existingEntity = state.entities[entity.entityId]; @@ -91,34 +213,37 @@ export function createDojoStoreFactory( entity as WritableDraft< ParsedEntity >; - return; + continue; } // Create new models object without spread const mergedModels: typeof existingEntity.models = Object.assign({}, existingEntity.models); // Iterate through each namespace in the new models - Object.entries(entity.models).forEach( - ([namespace, namespaceModels]) => { - const typedNamespace = - namespace as keyof ParsedEntity["models"]; - if (!(typedNamespace in mergedModels)) { - mergedModels[ - typedNamespace as keyof typeof mergedModels - ] = {} as any; - } - - // Use deep merge instead of Object.assign + for (const [ + namespace, + namespaceModels, + ] of Object.entries(entity.models)) { + const typedNamespace = + namespace as keyof ParsedEntity["models"]; + if (!(typedNamespace in mergedModels)) { + // @ts-expect-error TODO: change to better type mergedModels[ typedNamespace as keyof typeof mergedModels - ] = deepMerge( - mergedModels[ - typedNamespace as keyof typeof mergedModels - ] as MergedModels, - namespaceModels - ) as any; + ] = {} as Record; } - ); + + // Use deep merge instead of Object.assign + // @ts-expect-error TODO: change to better type + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] = deepMerge( + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] as MergedModels, + namespaceModels + ) as MergedModels; + } // Update the entity state.entities[entity.entityId] = { @@ -127,7 +252,7 @@ export function createDojoStoreFactory( models: mergedModels, }; } - }); + } }); }, updateEntity: (entity: Partial>) => { @@ -145,28 +270,30 @@ export function createDojoStoreFactory( // Create new models object without spread const mergedModels: typeof existingEntity.models = Object.assign({}, existingEntity.models); - // Iterate through each namespace in the new models - Object.entries(entity.models).forEach( - ([namespace, namespaceModels]) => { - const typedNamespace = - namespace as keyof ParsedEntity["models"]; - if (!(typedNamespace in mergedModels)) { - mergedModels[ - typedNamespace as keyof typeof mergedModels - ] = {} as any; - } - + for (const [ + namespace, + namespaceModels, + ] of Object.entries(entity.models)) { + const typedNamespace = + namespace as keyof ParsedEntity["models"]; + if (!(typedNamespace in mergedModels)) { + // @ts-expect-error TODO: change to better type mergedModels[ typedNamespace as keyof typeof mergedModels - ] = deepMerge( - mergedModels[ - typedNamespace as keyof typeof mergedModels - ] as MergedModels, - namespaceModels - ) as any; + ] = {} as Record; } - ); + + // @ts-expect-error TODO: change to better type + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] = deepMerge( + mergedModels[ + typedNamespace as keyof typeof mergedModels + ] as MergedModels, + namespaceModels + ) as MergedModels; + } // Update the entity state.entities[entity.entityId] = { ...existingEntity, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 8889cb28..c59f468d 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -455,6 +455,10 @@ export interface SDK { ) => Promise<[ToriiResponse, torii.EntityKeysClause[]]>; } +export type SDKClientConfig = Partial< + Omit +> & { worldAddress: torii.ClientConfig["worldAddress"] }; + /** * Configuration interface for the SDK. */ @@ -463,7 +467,7 @@ export interface SDKConfig { * Configuration for the Torii client. * This includes settings such as the endpoint URL, authentication details, etc. */ - client: torii.ClientConfig; + client: SDKClientConfig; /** * The Starknet domain configuration. diff --git a/packages/torii-wasm/package.json b/packages/torii-wasm/package.json index abfa6649..28df99c7 100644 --- a/packages/torii-wasm/package.json +++ b/packages/torii-wasm/package.json @@ -8,7 +8,6 @@ "type": "module", "scripts": { "build:wasm": "sh ./build.sh", - "build": "pnpm build:wasm", "format:check": "prettier --check .", "format": "prettier --write .", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/utils/src/_test_/utils/index.test.ts b/packages/utils/src/_test_/utils/index.test.ts index 5c81ebbf..154c2558 100644 --- a/packages/utils/src/_test_/utils/index.test.ts +++ b/packages/utils/src/_test_/utils/index.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { computeByteArrayHash, + convertToRelayUri, getComponentNameFromEvent, getSelectorFromTag, splitEventTag, @@ -104,4 +105,25 @@ describe("utils", () => { "0x2ac8b4c190f7031b9fc44312e6b047a1dce0b3f2957c33a935ca7846a46dd5b" ); }); + + it("should convert uri to multiaddr", () => { + expect(convertToRelayUri("http://localhost:8080")).toBe( + "/dns4/localhost/tcp/8080" + ); + expect(convertToRelayUri("http://127.0.0.1:8080")).toBe( + "/ip4/127.0.0.1/tcp/8080" + ); + expect( + convertToRelayUri( + "https://api.cartridge.gg/x/wordle-game/torii/wss" + ) + ).toBe( + "/dns4/api.cartridge.gg/tcp/443/%2Fx%2Fwordle-game%2Ftorii%2Fwss" + ); + expect( + convertToRelayUri("wss://api.cartridge.gg/x/wordle-game/torii/wss") + ).toBe( + "/dns4/api.cartridge.gg/tcp/443/x-parity-wss/%2Fx%2Fwordle-game%2Ftorii%2Fwss" + ); + }); }); diff --git a/packages/utils/src/utils/index.ts b/packages/utils/src/utils/index.ts index 7c57f80c..7bb5c314 100644 --- a/packages/utils/src/utils/index.ts +++ b/packages/utils/src/utils/index.ts @@ -373,3 +373,82 @@ export function shortenHex(hexString: string, numDigits = 6) { const secondHalf = hexString.slice(-halfDigits); return `${firstHalf}...${secondHalf}`; } + +/** + * Get the default port for a given protocol + * @param {string} protocol - The protocol to get the default port for + * @returns {number} The default port + */ +function getDefaultPortForProtocol(protocol: string): number { + switch (protocol) { + case "https:": + return 443; + case "wss:": + return 443; + default: + return 80; + } +} + +/** + * Converts a standard URI to multiaddr format + * Examples: + * - http://example.com → /dns4/example.com/tcp/80/http + * - https://example.com → /dns4/example.com/tcp/443/https + * - http://127.0.0.1:8080 → /ip4/127.0.0.1/tcp/8080/http + * - http://[::1]:8080 → /ip6/::1/tcp/8080/http + * + * @param {string} uri - The URI to convert (e.g., "http://example.com:8080/path") + * @returns {string} The multiaddr representation + */ +export function convertToRelayUri(uri: string): string { + try { + // Parse the URI + const url = new URL(uri); + + // Determine default port based on protocol + const defaultPort = getDefaultPortForProtocol(url.protocol); + const port = url.port || defaultPort; + const protocol = url.protocol.replace(":", ""); + + // Handle IP addresses and domains + let addrType: string; + let hostname = url.hostname; + + if (hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) { + // IPv4 address + addrType = "ip4"; + } else if (hostname.startsWith("[") && hostname.endsWith("]")) { + // IPv6 address in URL format [::1] + addrType = "ip6"; + hostname = hostname.slice(1, -1); + } else if (hostname.includes(":")) { + // IPv6 address without brackets + addrType = "ip6"; + } else { + // Domain name + addrType = "dns4"; + } + + // Construct the multiaddr + let multiaddr = `/${addrType}/${hostname}/tcp/${port}`; + + // Append protocol if it's not http or https + if (!["http", "https"].includes(protocol)) { + // Replace wss with x-parity-wss + const protocolToAppend = + protocol === "wss" ? "x-parity-wss" : protocol; + multiaddr += `/${protocolToAppend}`; + } + + // Add path if it exists and is not just "/" + if (url.pathname && url.pathname !== "/") { + multiaddr += `/${encodeURIComponent(url.pathname)}`; + } + + return multiaddr; + } catch (error) { + console.error("Invalid URI:", error); + return ""; + } +} diff --git a/turbo.json b/turbo.json index 163d50ba..5a682d32 100644 --- a/turbo.json +++ b/turbo.json @@ -16,7 +16,7 @@ "outputs": ["dist/**", "target/**", "pkg/**"] }, "build": { - "dependsOn": ["^build"], + "dependsOn": ["^build", "^build:wasm", "^build:deps"], "outputs": [ ".next/**", "!.next/cache/**",