diff --git a/packages/arcade-ts/src/marketplace/client.edge.test.ts b/packages/arcade-ts/src/marketplace/client.edge.test.ts index 30b80539..1ee625ab 100644 --- a/packages/arcade-ts/src/marketplace/client.edge.test.ts +++ b/packages/arcade-ts/src/marketplace/client.edge.test.ts @@ -1,24 +1,46 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { constants } from "starknet"; import { createEdgeMarketplaceClient } from "./client.edge"; -import { fetchToriisSql } from "../modules/torii-sql-fetcher"; +import { fetchToriis } from "../modules/torii-fetcher"; +import { fetchCollectionTokens, fetchTokenBalances } from "./tokens"; -vi.mock("../modules/torii-sql-fetcher", () => ({ - fetchToriisSql: vi.fn(), +vi.mock("../modules/torii-fetcher", () => ({ + fetchToriis: vi.fn(), })); -const mockedFetchToriisSql = vi.mocked(fetchToriisSql); +vi.mock("./tokens", () => ({ + fetchCollectionTokens: vi.fn(), + fetchTokenBalances: vi.fn(), +})); + +vi.mock("../modules/init-sdk", () => ({ + initArcadeSDK: vi.fn().mockResolvedValue({ + getEntities: vi.fn().mockResolvedValue({ getItems: () => [] }), + }), +})); + +const mockedFetchToriis = vi.mocked(fetchToriis); +const mockedFetchCollectionTokens = vi.mocked(fetchCollectionTokens); +const mockedFetchTokenBalances = vi.mocked(fetchTokenBalances); + +const emptyGrpcResponse = (items: any[] = []) => ({ + data: [{ items, next_cursor: null }], + metadata: { + totalEndpoints: 1, + successfulEndpoints: 1, + failedEndpoints: 0, + }, +}); describe("createEdgeMarketplaceClient", () => { beforeEach(() => { - mockedFetchToriisSql.mockReset(); + mockedFetchToriis.mockReset(); + mockedFetchCollectionTokens.mockReset(); + mockedFetchTokenBalances.mockReset(); }); it("returns null when collection is missing", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); + mockedFetchToriis.mockResolvedValueOnce(emptyGrpcResponse() as any); const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, @@ -30,97 +52,65 @@ describe("createEdgeMarketplaceClient", () => { }); expect(collection).toBeNull(); - expect(mockedFetchToriisSql).toHaveBeenCalledWith( + expect(mockedFetchToriis).toHaveBeenCalledWith( ["arcade-main"], - expect.stringContaining("FROM token_contracts"), + expect.objectContaining({ client: expect.any(Function) }), ); }); it("falls back to token sample lookup when contract metadata is missing", async () => { - mockedFetchToriisSql - .mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [ - { - contract_address: "0xabc", - contract_type: "ERC721", - metadata: null, - total_supply: "0x2", - token_id: null, - }, - ], - }, - ], - errors: [], - } as any) - .mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [ - { - token_id: "0x2", - metadata: JSON.stringify({ name: "Fallback metadata" }), - }, - ], - }, - ], - errors: [], - } as any); + // First call: getTokenContracts returns contract without metadata + mockedFetchToriis.mockResolvedValueOnce( + emptyGrpcResponse([ + { + contract_address: + "0x0000000000000000000000000000000000000000000000000000000000000001", + contract_type: "ERC721", + metadata: "", + total_supply: "1", + }, + ]) as any, + ); + // Second call: getTokens returns token with metadata + mockedFetchToriis.mockResolvedValueOnce( + emptyGrpcResponse([ + { + contract_address: "0x1", + token_id: "42", + metadata: JSON.stringify({ name: "Fallback Token" }), + }, + ]) as any, + ); const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); const collection = await client.getCollection({ - address: "0xabc", + address: "0x1", fetchImages: false, }); expect(collection).not.toBeNull(); - expect(collection?.tokenIdSample).toBe("0x2"); - expect(collection?.metadata).toMatchObject({ name: "Fallback metadata" }); - expect(mockedFetchToriisSql).toHaveBeenCalledTimes(2); - expect(mockedFetchToriisSql.mock.calls[1]?.[1]).toContain("FROM tokens"); + expect(collection?.tokenIdSample).toBe("42"); + expect(mockedFetchToriis).toHaveBeenCalledTimes(2); }); - it("returns null instead of throwing when getCollection SQL query fails", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [], - errors: [new Error("HTTP error! status: 400")], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, + it("lists tokens through gRPC transport via fetchCollectionTokens", async () => { + mockedFetchCollectionTokens.mockResolvedValueOnce({ + page: { + tokens: [ + { + contract_address: "0xabc", + token_id: "0x1", + metadata: { name: "Token 1" }, + } as any, + ], + nextCursor: null, + }, + error: null, }); - await expect( - client.getCollection({ - address: "0xabc", - fetchImages: false, - }), - ).resolves.toBeNull(); - }); - - it("lists tokens through SQL transport", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [ - { - contract_address: "0xabc", - token_id: "0x1", - metadata: JSON.stringify({ name: "Token 1" }), - }, - ], - }, - ], - errors: [], - } as any); - const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); @@ -133,368 +123,128 @@ describe("createEdgeMarketplaceClient", () => { expect(result.error).toBeNull(); expect(result.page?.tokens).toHaveLength(1); expect(result.page?.tokens[0]?.metadata?.name).toBe("Token 1"); - expect(mockedFetchToriisSql).toHaveBeenCalledWith( - ["arcade-main"], - expect.stringContaining("FROM tokens"), - ); - }); - - it("includes metadata projection by default when listing tokens", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.listCollectionTokens({ - address: "0xabc", - fetchImages: false, - }); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("SELECT contract_address, token_id, metadata"); - }); - - it("omits metadata projection when includeMetadata is false", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [{ contract_address: "0xabc", token_id: "0x1" }], - }, - ], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - const result = await client.listCollectionTokens({ - address: "0xabc", - includeMetadata: false, - fetchImages: true, - }); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain( - "SELECT contract_address, token_id, name, symbol, decimals", + expect(mockedFetchCollectionTokens).toHaveBeenCalledWith( + expect.objectContaining({ + address: "0xabc", + project: "arcade-main", + }), ); - expect(sql).not.toContain("metadata"); - expect(result.error).toBeNull(); - expect(result.page?.tokens[0]?.image).toContain("/torii/static/"); - }); - - it("normalizes tokenIds before building SQL IN clause", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.listCollectionTokens({ - address: "0xabc", - tokenIds: ["0x1"], - fetchImages: false, - }); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("token_id IN ('1')"); - }); - - it("canonicalizes equivalent decimal and hex tokenIds and deduplicates them", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.listCollectionTokens({ - address: "0xabc", - tokenIds: ["ff", "0xff", "255"], - fetchImages: false, - }); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("'255'"); - expect(sql).not.toContain("'0xff'"); - expect(sql).not.toContain("'ff'"); - expect((sql.match(/'255'/g) ?? []).length).toBe(1); - }); - - it("chunks large tokenId filters instead of building one unbounded IN list", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.listCollectionTokens({ - address: "0xabc", - tokenIds: Array.from({ length: 450 }, (_, index) => String(index + 1)), - fetchImages: false, - }); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - const inClauseCount = (sql.match(/token_id IN \(/g) ?? []).length; - expect(inClauseCount).toBeGreaterThan(1); - expect(sql).toContain(" OR token_id IN ("); }); it("hydrates metadata for normalized token ids through batch API", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [ - { - contract_address: "0xabc", - token_id: "255", - metadata: JSON.stringify({ name: "Token 255" }), - }, - ], - }, - ], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - const tokens = await client.getCollectionTokenMetadataBatch({ - address: "0xabc", - tokenIds: ["ff", "0xff", "255"], - fetchImages: false, + mockedFetchCollectionTokens.mockResolvedValueOnce({ + page: { + tokens: [ + { + contract_address: "0xabc", + token_id: "1", + metadata: { name: "Token 1" }, + } as any, + { + contract_address: "0xabc", + token_id: "2", + metadata: { name: "Token 2" }, + } as any, + ], + nextCursor: null, + }, + error: null, }); - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("FROM tokens"); - expect(sql).toContain("'255'"); - expect((sql.match(/'255'/g) ?? []).length).toBe(1); - expect(tokens).toHaveLength(1); - expect(tokens[0]?.metadata?.name).toBe("Token 255"); - }); - - it("returns empty metadata batch and avoids SQL when token ids are invalid", async () => { const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); const tokens = await client.getCollectionTokenMetadataBatch({ address: "0xabc", - tokenIds: ["", " "], - fetchImages: false, + tokenIds: ["1", "2"], }); - expect(tokens).toEqual([]); - expect(mockedFetchToriisSql).not.toHaveBeenCalled(); + expect(tokens).toHaveLength(2); + expect(mockedFetchCollectionTokens).toHaveBeenCalledWith( + expect.objectContaining({ + includeMetadata: true, + }), + ); }); - it("chunks metadata hydration for large token id sets", async () => { - mockedFetchToriisSql.mockResolvedValue({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - + it("returns empty metadata batch when token ids are invalid", async () => { const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); - await client.getCollectionTokenMetadataBatch({ + const tokens = await client.getCollectionTokenMetadataBatch({ address: "0xabc", - tokenIds: Array.from({ length: 450 }, (_, index) => String(index + 1)), - fetchImages: false, + tokenIds: [], }); - expect(mockedFetchToriisSql.mock.calls.length).toBeGreaterThan(1); + expect(tokens).toHaveLength(0); + expect(mockedFetchCollectionTokens).not.toHaveBeenCalled(); }); - it("returns null nextCursor when limit is invalid and no rows are returned", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - + it("uses SDK entity queries for getCollectionOrders", async () => { const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); - const result = await client.listCollectionTokens({ - address: "0xabc", - limit: 0, - fetchImages: false, + const orders = await client.getCollectionOrders({ + collection: "0xabc", }); - expect(result.error).toBeNull(); - expect(result.page?.nextCursor).toBeNull(); + expect(orders).toEqual([]); }); - it("uses keyset pagination for token queries and emits keyset cursor", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [ - { - contract_address: "0xabc", - token_id: "2", - metadata: JSON.stringify({ name: "Token 2" }), - }, - ], + it("verifies listing ownership via gRPC token balances", async () => { + // Mock SDK to return a placed sell order + const mockOrder = { + models: { + ARCADE: { + Order: { + id: 1, + category: 2, // Sell + status: 1, // Placed + expiration: Math.floor(Date.now() / 1000) + 3600, + collection: "0xabc", + token_id: 1, + quantity: 1, + price: 100, + currency: "0x0", + owner: "0x123", + }, }, - ], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - const result = await client.listCollectionTokens({ - address: "0xabc", - limit: 1, - fetchImages: false, - }); - - expect(result.error).toBeNull(); - expect(result.page?.nextCursor).toBe("keyset:2"); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).not.toContain("OFFSET"); - }); - - it("applies keyset cursor to token query predicate", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.listCollectionTokens({ - address: "0xabc", - cursor: "keyset:9", - limit: 25, - fetchImages: false, - }); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("token_id > '9'"); - expect(sql).not.toContain("OFFSET"); - }); - - it("pushes attribute filters into SQL instead of filtering client-side", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.listCollectionTokens({ - address: "0xabc", - attributeFilters: { - rarity: new Set(["legendary", "epic"]), - class: new Set(["wizard"]), }, - fetchImages: false, - }); + }; - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("FROM token_attributes"); - expect(sql).toContain("HAVING COUNT(DISTINCT trait_name) = 2"); - expect(sql).toContain("trait_name = 'rarity'"); - expect(sql).toContain("trait_name = 'class'"); - }); - - it("applies default order limit when getCollectionOrders limit is omitted", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], + const { initArcadeSDK } = await import("../modules/init-sdk"); + vi.mocked(initArcadeSDK).mockResolvedValueOnce({ + getEntities: vi.fn().mockResolvedValue({ + getItems: () => [mockOrder], + }), } as any); - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - await client.getCollectionOrders({ - collection: "0xabc", + mockedFetchTokenBalances.mockResolvedValueOnce({ + page: { balances: [], nextCursor: null }, + error: null, }); - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("ORDER BY id DESC LIMIT 100"); - }); - - it("short-circuits invalid orderIds without issuing malformed SQL", async () => { - mockedFetchToriisSql.mockResolvedValue({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any); - const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); - const orders = await client.getCollectionOrders({ + const listings = await client.listCollectionListings({ collection: "0xabc", - orderIds: [Number.NaN as unknown as number], + verifyOwnership: true, }); - expect(orders).toEqual([]); - expect(mockedFetchToriisSql).not.toHaveBeenCalled(); + // Order won't pass ownership check since balance is empty + expect(listings).toEqual([]); }); - it("chunks ownership verification queries for large listing sets", async () => { - const orderRows = Array.from({ length: 450 }, (_, index) => ({ - id: index + 1, - category: 2, - status: 1, - expiration: 9999999999, - collection: "0xabc", - token_id: index + 1, - quantity: 1, - price: 1, - currency: "0x1", - owner: "0x123", - })); - - mockedFetchToriisSql.mockImplementation(async (_projects, sql) => { - if (sql.includes('FROM "ARCADE-Order"')) { - return { - data: [{ endpoint: "arcade-main", data: orderRows }], - errors: [], - } as any; - } - - if (sql.includes("FROM token_balances")) { - return { - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any; - } - - return { - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], - } as any; - }); + it("skips ownership verification when verifyOwnership is false", async () => { + const { initArcadeSDK } = await import("../modules/init-sdk"); + vi.mocked(initArcadeSDK).mockResolvedValueOnce({ + getEntities: vi.fn().mockResolvedValue({ getItems: () => [] }), + } as any); const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, @@ -502,70 +252,19 @@ describe("createEdgeMarketplaceClient", () => { const listings = await client.listCollectionListings({ collection: "0xabc", - limit: 450, - verifyOwnership: true, - projectId: "arcade-main", + verifyOwnership: false, }); expect(listings).toEqual([]); - - const ownershipQueries = mockedFetchToriisSql.mock.calls - .map((call) => call[1]) - .filter((sql) => sql.includes("FROM token_balances")); - - expect(ownershipQueries.length).toBeGreaterThan(1); + expect(mockedFetchTokenBalances).not.toHaveBeenCalled(); }); - it("relies on SQL category and status filtering for verifyOwnership=false listings", async () => { - mockedFetchToriisSql.mockResolvedValueOnce({ - data: [ - { - endpoint: "arcade-main", - data: [ - { - id: 1, - category: 2, - status: 1, - expiration: 9999999999, - collection: "0xabc", - token_id: 1, - quantity: 1, - price: 1, - currency: "0x1", - owner: "0x123", - }, - { - id: 2, - category: 1, - status: 2, - expiration: 9999999999, - collection: "0xabc", - token_id: 2, - quantity: 1, - price: 1, - currency: "0x1", - owner: "0x123", - }, - ], - }, - ], - errors: [], - } as any); - + it("uses SDK entity queries for getFees", async () => { const client = await createEdgeMarketplaceClient({ chainId: constants.StarknetChainId.SN_MAIN, }); - const listings = await client.listCollectionListings({ - collection: "0xabc", - verifyOwnership: false, - projectId: "arcade-main", - }); - - expect(listings).toHaveLength(2); - - const sql = mockedFetchToriisSql.mock.calls[0]?.[1] ?? ""; - expect(sql).toContain("category = 2"); - expect(sql).toContain("status = 1"); + const fees = await client.getFees(); + expect(fees).toBeNull(); }); }); diff --git a/packages/arcade-ts/src/marketplace/client.edge.ts b/packages/arcade-ts/src/marketplace/client.edge.ts index 72aa89f1..b2502b73 100644 --- a/packages/arcade-ts/src/marketplace/client.edge.ts +++ b/packages/arcade-ts/src/marketplace/client.edge.ts @@ -1,7 +1,37 @@ +import { + AndComposeClause, + ClauseBuilder, + KeysClause, + MemberClause, + OrComposeClause, + ToriiQueryBuilder, +} from "@dojoengine/sdk"; +import type { constants } from "starknet"; import { addAddressPadding, cairo, getChecksumAddress } from "starknet"; -import { fetchToriisSql } from "../modules/torii-sql-fetcher"; +import type { + ToriiClient, + Token as ToriiToken, +} from "@dojoengine/torii-wasm/types"; +import { + fetchToriis, + type ClientCallbackParams, +} from "../modules/torii-fetcher"; +import { initArcadeSDK } from "../modules/init-sdk"; +import type { SchemaType } from "../bindings"; +import { ArcadeModelsMapping, OrderCategory, OrderStatus } from "../bindings"; +import { NAMESPACE } from "../constants"; +import { Book } from "../modules/marketplace/book"; +import { Order, type OrderModel } from "../modules/marketplace/order"; import { CategoryType, StatusType } from "../classes"; -import { OrderModel } from "../modules/marketplace/order"; +import { fetchCollectionTokens, fetchTokenBalances } from "./tokens"; +import { + canonicalizeTokenId, + defaultResolveContractImage, + defaultResolveTokenImage, + inferImageFromMetadata, + normalizeTokenIds, + parseJsonSafe, +} from "./utils"; import type { CollectionTokenMetadataBatchOptions, CollectionListingsOptions, @@ -19,48 +49,22 @@ import type { TokenDetails, TokenDetailsOptions, } from "./types"; -import { - canonicalizeTokenId, - defaultResolveContractImage, - defaultResolveTokenImage, - inferImageFromMetadata, - normalizeTokenIds, - normalizeTokens, - parseJsonSafe, -} from "./utils"; -const DEFAULT_LIMIT = 100; -const SQL_IN_CHUNK_SIZE = 200; +type TokenContractsResponse = Awaited< + ReturnType +>; -const statusValueMap: Record = { - [StatusType.None]: 0, - [StatusType.Placed]: 1, - [StatusType.Canceled]: 2, - [StatusType.Executed]: 3, +const statusMap: Record = { + [StatusType.None]: OrderStatus.None, + [StatusType.Placed]: OrderStatus.Placed, + [StatusType.Canceled]: OrderStatus.Canceled, + [StatusType.Executed]: OrderStatus.Executed, }; -const categoryValueMap: Record = { - [CategoryType.None]: 0, - [CategoryType.Buy]: 1, - [CategoryType.Sell]: 2, -}; - -const asNumber = (value: unknown): number => { - if (typeof value === "number") return Number.isFinite(value) ? value : 0; - if (typeof value === "bigint") return Number(value); - if (typeof value === "string") { - if (value.startsWith("0x")) return Number(BigInt(value)); - const n = Number(value); - return Number.isFinite(n) ? n : 0; - } - return 0; -}; - -const asBigInt = (value: unknown): bigint => { - if (typeof value === "bigint") return value; - if (typeof value === "number") return BigInt(Math.trunc(value)); - if (typeof value === "string" && value.length > 0) return BigInt(value); - return 0n; +const categoryMap: Record = { + [CategoryType.None]: OrderCategory.None, + [CategoryType.Buy]: OrderCategory.Buy, + [CategoryType.Sell]: OrderCategory.Sell, }; const normalizeTokenIdForQuery = (tokenId?: string): string | undefined => { @@ -76,162 +80,101 @@ const ensureProjectId = ( return fallback; }; -const escapeSqlValue = (value: string): string => value.replace(/'/g, "''"); - -const extractRows = (data: any): any[] => { - if (!data) return []; - if (Array.isArray(data)) return data; - if (Array.isArray(data.data)) return data.data; - if (Array.isArray(data.rows)) return data.rows; - if (Array.isArray(data.result)) return data.result; - return []; -}; - -const toSqlList = (values: string[]): string => - values.map((value) => `'${escapeSqlValue(value)}'`).join(", "); - -const toPositiveInt = (value: number, fallback: number): number => { - if (!Number.isFinite(value)) return fallback; - const intValue = Math.floor(value); - if (intValue <= 0) return fallback; - return intValue; -}; - -const chunkArray = (values: T[], size: number): T[][] => { - if (values.length === 0) return []; - if (size <= 0 || values.length <= size) return [values]; - - const chunks: T[][] = []; - for (let index = 0; index < values.length; index += size) { - chunks.push(values.slice(index, index + size)); - } - return chunks; -}; - -const buildChunkedInPredicate = ( - column: string, - values: string[], - chunkSize = SQL_IN_CHUNK_SIZE, -): string => { - const chunks = chunkArray(values, chunkSize); - if (chunks.length === 0) return "1 = 0"; - if (chunks.length === 1) return `${column} IN (${toSqlList(chunks[0])})`; - return `(${chunks - .map((chunk) => `${column} IN (${toSqlList(chunk)})`) - .join(" OR ")})`; -}; +async function fetchContractMetadata( + projectId: string, + address: string, + resolveContractImage: MarketplaceClientConfig["resolveContractImage"], + fetchImages: boolean, +): Promise { + const checksumAddress = getChecksumAddress(address); + + const response = await fetchToriis([projectId], { + client: async ({ client }: ClientCallbackParams) => { + return client.getTokenContracts({ + contract_addresses: [checksumAddress], + contract_types: [], + pagination: { + limit: 1, + cursor: undefined, + direction: "Forward", + order_by: [], + }, + }); + }, + }); -const buildTokenProjectionSql = (includeMetadata: boolean): string => - includeMetadata - ? "contract_address, token_id, metadata, name, symbol, decimals" - : "contract_address, token_id, name, symbol, decimals"; - -const KEYSET_CURSOR_PREFIX = "keyset:"; - -const parseTokenCursor = ( - cursor?: string | null | undefined, -): { offset?: number; keysetTokenId?: string } => { - if (!cursor) return {}; - if (cursor.startsWith(KEYSET_CURSOR_PREFIX)) { - const tokenId = cursor.slice(KEYSET_CURSOR_PREFIX.length); - if (!tokenId) return {}; - return { keysetTokenId: tokenId }; - } + const contractPages = response.data as TokenContractsResponse[]; + const contract = contractPages + .flatMap((page) => page.items) + .find( + (item) => getChecksumAddress(item.contract_address) === checksumAddress, + ); - const numericCursor = Number.parseInt(cursor, 10); - if (Number.isFinite(numericCursor) && `${numericCursor}` === cursor.trim()) { - return { offset: Math.max(0, numericCursor) }; - } + if (!contract) return null; - return { keysetTokenId: cursor }; -}; + let tokenSample: ToriiToken | undefined; -const encodeKeysetCursor = (tokenId: string): string => - `${KEYSET_CURSOR_PREFIX}${tokenId}`; + if (!contract.metadata || contract.metadata.length === 0) { + try { + const tokensResponse = await fetchToriis([projectId], { + client: async ({ client }: ClientCallbackParams) => { + return client.getTokens({ + contract_addresses: [checksumAddress], + token_ids: [], + attribute_filters: [], + pagination: { + limit: 1, + cursor: undefined, + direction: "Forward", + order_by: [], + }, + }); + }, + }); -const buildAttributeFilterSqlClause = ( - collectionAddress: string, - filters: FetchCollectionTokensOptions["attributeFilters"], -): string | null => { - if (!filters || Object.keys(filters).length === 0) return null; - - const traitClauses: string[] = []; - const distinctTraits = new Set(); - - for (const [trait, values] of Object.entries(filters)) { - if (values == null) continue; - const selectedValues = Array.isArray(values) - ? values - : Array.from(values as Iterable); - const normalizedValues = selectedValues - .map((value) => String(value)) - .filter((value) => value.length > 0); - if (normalizedValues.length === 0) continue; - - distinctTraits.add(trait); - - const traitName = escapeSqlValue(trait); - if (normalizedValues.length === 1) { - traitClauses.push( - `(trait_name = '${traitName}' AND trait_value = '${escapeSqlValue( - normalizedValues[0], - )}')`, - ); - continue; + const tokenPages = tokensResponse.data as Awaited< + ReturnType + >[]; + tokenSample = tokenPages.flatMap((page) => page.items)[0]; + if (tokenSample?.metadata && !contract.metadata) { + contract.metadata = tokenSample.metadata; + } + if (tokenSample?.token_id && !(contract as any).token_id) { + (contract as any).token_id = tokenSample.token_id; + } + } catch (_error) { + // Silently ignore token metadata enrichment failures } - - traitClauses.push( - `(trait_name = '${traitName}' AND trait_value IN (${toSqlList( - normalizedValues, - )}))`, - ); - } - - if (traitClauses.length === 0) return null; - - return `token_id IN ( - SELECT token_id - FROM token_attributes - WHERE token_id LIKE '${escapeSqlValue(collectionAddress)}:%' - AND (${traitClauses.join(" OR ")}) - GROUP BY token_id - HAVING COUNT(DISTINCT trait_name) = ${distinctTraits.size} -)`; -}; - -async function querySql(projectId: string, sql: string): Promise { - const result = await fetchToriisSql([projectId], sql); - if (result.errors?.length) { - throw result.errors[0]; } - const rows: any[] = []; - for (const entry of result.data ?? []) { - rows.push(...extractRows(entry)); + const metadata = parseJsonSafe(contract.metadata, contract.metadata); + const totalSupply = BigInt(contract.total_supply ?? "0x0"); + const contractType = + (contract as any).contract_type ?? (contract as any).type ?? "ERC721"; + + let image: string | undefined; + if (fetchImages) { + const contractImageResolver = + resolveContractImage ?? defaultResolveContractImage; + const maybeImage = await contractImageResolver(contract, { projectId }); + if (typeof maybeImage === "string" && maybeImage.length > 0) { + image = maybeImage; + } + if (!image) { + image = inferImageFromMetadata(metadata); + } } - return rows; -} -function toOrderModel(row: any): OrderModel { - const orderLike = { - id: asNumber(row.id), - category: asNumber(row.category), - status: asNumber(row.status), - expiration: asNumber(row.expiration), - collection: String(row.collection ?? "0x0"), - token_id: asNumber(row.token_id), - quantity: asNumber(row.quantity), - price: asNumber(row.price), - currency: String(row.currency ?? "0x0"), - owner: String(row.owner ?? "0x0"), + return { + projectId, + address: checksumAddress, + contractType, + metadata, + totalSupply, + tokenIdSample: (contract as any).token_id ?? tokenSample?.token_id ?? null, + image, + raw: contract, }; - - const identifier = - typeof row.entity_id === "string" - ? row.entity_id - : `${orderLike.id}:${orderLike.collection}:${orderLike.token_id}`; - - return OrderModel.from(identifier, orderLike); } async function verifyListingsOwnership( @@ -241,42 +184,34 @@ async function verifyListingsOwnership( ): Promise { if (!listings.length) return listings; - const collection = addAddressPadding( - getChecksumAddress(collectionAddress), - ).toLowerCase(); - const owners = [ + const checksumCollection = getChecksumAddress(collectionAddress); + const ownerAddresses = [ ...new Set(listings.map((order) => getChecksumAddress(order.owner))), ]; - if (owners.length === 0) return []; + if (ownerAddresses.length === 0) return []; const tokenIds = [ - ...new Set(listings.map((order) => BigInt(order.tokenId).toString())), + ...new Set( + listings.map((o) => addAddressPadding(`0x${o.tokenId.toString(16)}`)), + ), ]; if (tokenIds.length === 0) return []; - const ownership = new Set(); - const ownerChunks = chunkArray( - owners.map((owner) => owner.toLowerCase()), - SQL_IN_CHUNK_SIZE, - ); - const tokenIdChunks = chunkArray(tokenIds, SQL_IN_CHUNK_SIZE); - - for (const ownerChunk of ownerChunks) { - for (const tokenIdChunk of tokenIdChunks) { - const sql = `SELECT account_address, token_id -FROM token_balances -WHERE contract_address = '${escapeSqlValue(collection)}' - AND account_address IN (${toSqlList(ownerChunk)}) - AND token_id IN (${toSqlList(tokenIdChunk)}) - AND balance != '0x0000000000000000000000000000000000000000000000000000000000000000'`; - - const rows = await querySql(projectId, sql); - - for (const row of rows) { - const owner = getChecksumAddress(String(row.account_address)); - const tokenId = BigInt(String(row.token_id)).toString(); - ownership.add(`${owner}_${tokenId}`); - } + const { page, error } = await fetchTokenBalances({ + project: projectId, + contractAddresses: [checksumCollection], + accountAddresses: ownerAddresses, + tokenIds, + }); + + if (error || !page) return []; + + const ownership = new Set(); + for (const balance of page.balances) { + if (BigInt(balance.balance) > 0n && balance.token_id) { + const owner = getChecksumAddress(balance.account_address); + const tokenId = BigInt(balance.token_id).toString(); + ownership.add(`${owner}_${tokenId}`); } } @@ -291,320 +226,205 @@ export async function createEdgeMarketplaceClient( config: MarketplaceClientConfig, ): Promise { const { + chainId, defaultProject = "arcade-main", resolveTokenImage, resolveContractImage, provider, } = config; + const sdk = await initArcadeSDK(chainId as constants.StarknetChainId); + const getCollection = async ( options: CollectionSummaryOptions, ): Promise => { const { projectId: projectIdInput, address, fetchImages = true } = options; const projectId = ensureProjectId(projectIdInput, defaultProject); - const collection = addAddressPadding( - getChecksumAddress(address), - ).toLowerCase(); - try { - const rows = await querySql( - projectId, - `SELECT - contract_address, - contract_type, - type, - metadata, - total_supply, - token_id -FROM token_contracts -WHERE contract_address = '${escapeSqlValue(collection)}' -LIMIT 1`, - ); - - const contract = rows[0]; - if (!contract) return null; - - let tokenSample: { token_id?: string; metadata?: unknown } | undefined; - const requiresTokenFallback = - contract.metadata == null || contract.token_id == null; - - if (requiresTokenFallback) { - try { - const tokenRows = await querySql( - projectId, - `SELECT token_id, metadata -FROM tokens -WHERE contract_address = '${escapeSqlValue(collection)}' -ORDER BY token_id -LIMIT 1`, - ); - tokenSample = tokenRows[0]; - } catch (_error) { - tokenSample = undefined; - } - } - - const metadata = parseJsonSafe( - contract.metadata ?? tokenSample?.metadata, - contract.metadata ?? tokenSample?.metadata ?? null, - ); - let image: string | undefined; - - if (fetchImages) { - const contractImageResolver = - resolveContractImage ?? defaultResolveContractImage; - const maybeImage = await contractImageResolver(contract as any, { - projectId, - }); - if (typeof maybeImage === "string" && maybeImage.length > 0) { - image = maybeImage; - } - if (!image) image = inferImageFromMetadata(metadata); - } - - return { - projectId, - address: getChecksumAddress( - String(contract.contract_address ?? collection), - ), - contractType: - String(contract.contract_type ?? contract.type ?? "ERC721") || - "ERC721", - metadata, - totalSupply: asBigInt(contract.total_supply ?? "0x0"), - tokenIdSample: - (contract.token_id as string | null | undefined) ?? - (tokenSample?.token_id as string | null | undefined) ?? - null, - image, - raw: contract as any, - }; - } catch (_error) { - return null; - } + return fetchContractMetadata( + projectId, + address, + resolveContractImage, + fetchImages, + ); }; const listCollectionTokens = async ( options: FetchCollectionTokensOptions, ): Promise => { - const { - address, - project, - cursor, - attributeFilters, - tokenIds, - limit = DEFAULT_LIMIT, - includeMetadata = true, - fetchImages = false, - } = options; - const projectId = ensureProjectId(project, defaultProject); - const collection = addAddressPadding( - getChecksumAddress(address), - ).toLowerCase(); - const cursorState = parseTokenCursor(cursor); - const effectiveLimit = toPositiveInt(limit, DEFAULT_LIMIT); - const normalizedTokenIds = normalizeTokenIds(tokenIds); - - const conditions = [`contract_address = '${escapeSqlValue(collection)}'`]; - - if (normalizedTokenIds.length > 0) { - const values = [...new Set(normalizedTokenIds)]; - conditions.push(buildChunkedInPredicate("token_id", values)); - } - - if (cursorState.keysetTokenId) { - conditions.push( - `token_id > '${escapeSqlValue(cursorState.keysetTokenId)}'`, - ); - } - - const traitClause = buildAttributeFilterSqlClause( - collection, - attributeFilters, - ); - if (traitClause) { - conditions.push(traitClause); - } - - const projection = buildTokenProjectionSql(includeMetadata); - const sql = `SELECT ${projection} -FROM tokens -WHERE ${conditions.join(" AND ")} -ORDER BY token_id -LIMIT ${effectiveLimit}${ - cursorState.offset != null - ? ` -OFFSET ${cursorState.offset}` - : "" - }`; - - try { - const rows = await querySql(projectId, sql); - const normalized = await normalizeTokens(rows as any[], projectId, { - fetchImages, - resolveTokenImage: resolveTokenImage ?? defaultResolveTokenImage, - }); - - let nextCursor: string | null = null; - if (rows.length >= effectiveLimit) { - if (cursorState.offset != null) { - nextCursor = String(cursorState.offset + rows.length); - } else { - const lastRow = rows[rows.length - 1]; - const lastTokenId = lastRow?.token_id; - if (lastTokenId != null) { - nextCursor = encodeKeysetCursor(String(lastTokenId)); - } - } - } - return { - page: { - tokens: normalized as NormalizedToken[], - nextCursor, - }, - error: null, - }; - } catch (error) { - const err = - error instanceof Error - ? error - : new Error( - typeof error === "string" ? error : "Failed to list tokens", - ); - return { page: null, error: { error: err } }; - } + return fetchCollectionTokens({ + ...options, + project: options.project ?? defaultProject, + resolveTokenImage: resolveTokenImage ?? defaultResolveTokenImage, + defaultProjectId: defaultProject, + }); }; const getCollectionTokenMetadataBatch = async ( options: CollectionTokenMetadataBatchOptions, ): Promise => { - const { address, tokenIds, project, fetchImages = false } = options; - const projectId = ensureProjectId(project, defaultProject); - const collection = addAddressPadding( - getChecksumAddress(address), - ).toLowerCase(); - const normalizedTokenIds = [...new Set(normalizeTokenIds(tokenIds))]; - + const projectId = ensureProjectId(options.project, defaultProject); + const normalizedTokenIds = [ + ...new Set(normalizeTokenIds(options.tokenIds)), + ]; if (normalizedTokenIds.length === 0) { return []; } - const tokenIdChunks = chunkArray(normalizedTokenIds, SQL_IN_CHUNK_SIZE); - const hydratedRows: any[] = []; - - for (const tokenIdChunk of tokenIdChunks) { - if (tokenIdChunk.length === 0) continue; - - const sql = `SELECT ${buildTokenProjectionSql(true)} -FROM tokens -WHERE contract_address = '${escapeSqlValue(collection)}' - AND token_id IN (${toSqlList(tokenIdChunk)}) -ORDER BY token_id`; - - const rows = await querySql(projectId, sql); - hydratedRows.push(...rows); + const { page, error } = await fetchCollectionTokens({ + address: options.address, + project: projectId, + tokenIds: normalizedTokenIds, + limit: normalizedTokenIds.length, + includeMetadata: true, + fetchImages: options.fetchImages ?? false, + resolveTokenImage: resolveTokenImage ?? defaultResolveTokenImage, + defaultProjectId: defaultProject, + }); + if (error) { + throw error.error; } - const normalizedTokens = await normalizeTokens( - hydratedRows as any[], - projectId, - { - fetchImages, - resolveTokenImage: resolveTokenImage ?? defaultResolveTokenImage, - }, - ); - - const tokensByCanonicalId = new Map(); - for (const token of normalizedTokens as NormalizedToken[]) { + const tokensById = new Map(); + for (const token of page?.tokens ?? []) { const canonicalId = canonicalizeTokenId(String(token.token_id ?? "")); if (!canonicalId) continue; - if (!tokensByCanonicalId.has(canonicalId)) { - tokensByCanonicalId.set(canonicalId, token); + if (!tokensById.has(canonicalId)) { + tokensById.set(canonicalId, token); } } return normalizedTokenIds - .map((tokenId) => tokensByCanonicalId.get(tokenId)) + .map((tokenId) => tokensById.get(tokenId)) .filter((token): token is NormalizedToken => Boolean(token)); }; - const getCollectionOrders = async ( + const queryOrders = async ( options: CollectionOrdersOptions, - projectIdOverride?: string, ): Promise => { - const collection = addAddressPadding( - getChecksumAddress(options.collection), - ).toLowerCase(); + const checksumCollection = getChecksumAddress(options.collection); const tokenId = normalizeTokenIdForQuery(options.tokenId); - const status = - options.status != null ? statusValueMap[options.status] : undefined; - const category = - options.category != null ? categoryValueMap[options.category] : undefined; - - const normalizedOrderIds = options.orderIds?.length - ? [ - ...new Set( - options.orderIds - .map((id) => Number(id)) - .filter((id) => Number.isInteger(id) && id >= 0), - ), - ] - : []; - - if (options.orderIds?.length && normalizedOrderIds.length === 0) { - return []; + const orderIds = options.orderIds ?? []; + + let baseClause: + | ReturnType + | ReturnType; + + if (orderIds.length > 0) { + const orderIdClauses = orderIds.map((id) => + KeysClause( + [ArcadeModelsMapping.Order], + [ + id.toString(), + addAddressPadding(checksumCollection), + tokenId, + undefined, + ], + "FixedLen", + ), + ); + baseClause = + orderIdClauses.length === 1 + ? orderIdClauses[0] + : OrComposeClause(orderIdClauses); + } else { + baseClause = KeysClause( + [ArcadeModelsMapping.Order], + [undefined, addAddressPadding(checksumCollection), tokenId, undefined], + "FixedLen", + ); } - const conditions = [`collection = '${escapeSqlValue(collection)}'`]; - if (tokenId !== undefined) { - conditions.push(`token_id = '${escapeSqlValue(tokenId)}'`); + const builders: Array< + | ReturnType + | ReturnType + | ReturnType + > = [baseClause]; + + const status = + options.status != null ? statusMap[options.status] : undefined; + if (status !== undefined) { + builders.push( + MemberClause( + ArcadeModelsMapping.Order, + "status", + "Eq", + status.toString(), + ), + ); } - if (normalizedOrderIds.length) { - conditions.push(`id IN (${normalizedOrderIds.join(", ")})`); + + const category = + options.category != null ? categoryMap[options.category] : undefined; + if (category !== undefined && category !== OrderCategory.None) { + builders.push( + MemberClause( + ArcadeModelsMapping.Order, + "category", + "Eq", + category.toString(), + ), + ); } - if (status !== undefined) { - conditions.push(`status = ${status}`); + + const query = new ToriiQueryBuilder() + .withClause( + builders.length === 1 + ? builders[0].build() + : AndComposeClause(builders).build(), + ) + .withEntityModels([ArcadeModelsMapping.Order]) + .includeHashedKeys(); + + if (options.limit) { + query.withLimit(options.limit); } - if ( - category !== undefined && - category !== categoryValueMap[CategoryType.None] - ) { - conditions.push(`category = ${category}`); + + const entities = await sdk.getEntities({ query }); + const items = entities?.getItems() ?? []; + + const orders: OrderModel[] = []; + for (const entity of items) { + const model = entity.models[NAMESPACE]?.[Order.getModelName()]; + if (!model) continue; + const order = Order.parse(entity); + if (order.exists()) { + orders.push(order); + } } - const effectiveLimit = toPositiveInt( - options.limit ?? DEFAULT_LIMIT, - DEFAULT_LIMIT, - ); - const sql = `SELECT id, category, status, expiration, collection, token_id, quantity, price, currency, owner -FROM "ARCADE-Order" -WHERE ${conditions.join(" AND ")} -ORDER BY id DESC LIMIT ${effectiveLimit}`; + return orders; + }; - const rows = await querySql(projectIdOverride ?? defaultProject, sql); - return rows.map(toOrderModel).filter((order) => order.exists()); + const getCollectionOrders = async ( + options: CollectionOrdersOptions, + ): Promise => { + return queryOrders(options); }; const listCollectionListings = async ( options: CollectionListingsOptions, ): Promise => { - const projectId = ensureProjectId(options.projectId, defaultProject); - const baseOrders = await getCollectionOrders( - { - collection: options.collection, - tokenId: options.tokenId, - limit: options.limit, - category: CategoryType.Sell, - status: StatusType.Placed, - }, - projectId, + const baseOrders = await queryOrders({ + collection: options.collection, + tokenId: options.tokenId, + limit: options.limit, + category: CategoryType.Sell, + status: StatusType.Placed, + }); + + const filtered = baseOrders.filter( + (order) => + order.category.value === CategoryType.Sell && + order.status.value === StatusType.Placed, ); - if (options.verifyOwnership === false || baseOrders.length === 0) { - return baseOrders; + if (options.verifyOwnership === false || filtered.length === 0) { + return filtered; } - return verifyListingsOwnership(projectId, options.collection, baseOrders); + const projectId = ensureProjectId(options.projectId, defaultProject); + return verifyListingsOwnership(projectId, options.collection, filtered); }; const getToken = async ( @@ -617,30 +437,30 @@ ORDER BY id DESC LIMIT ${effectiveLimit}`; fetchImages = true, orderLimit, } = options; - const projectId = ensureProjectId(projectOverride, defaultProject); - const tokenPage = await listCollectionTokens({ + const projectId = ensureProjectId(projectOverride, defaultProject); + const { page, error } = await fetchCollectionTokens({ address: collection, project: projectId, tokenIds: [tokenId], limit: 1, fetchImages, + resolveTokenImage: resolveTokenImage ?? defaultResolveTokenImage, + defaultProjectId: defaultProject, }); - if (tokenPage.error) { - throw tokenPage.error.error; + + if (error) { + throw error.error; } - const token = tokenPage.page?.tokens[0]; + const token = page?.tokens[0]; if (!token) return null; - const orders = await getCollectionOrders( - { - collection, - tokenId, - limit: orderLimit, - }, - projectId, - ); + const orders = await queryOrders({ + collection, + tokenId, + limit: orderLimit, + }); const now = Date.now() / 1000; let listings = orders.filter( @@ -663,21 +483,29 @@ ORDER BY id DESC LIMIT ${effectiveLimit}`; }; const getFees = async (): Promise => { - const rows = await querySql( - defaultProject, - `SELECT fee_num, fee_receiver -FROM "ARCADE-Book" -LIMIT 1`, + const clauses = new ClauseBuilder().keys( + [`${NAMESPACE}-${Book.getModelName()}`], + [], ); - - const row = rows[0]; - if (!row) return null; - - return { - feeNum: asNumber(row.fee_num), - feeReceiver: getChecksumAddress(String(row.fee_receiver ?? "0x0")), - feeDenominator: 10000, - }; + const query = new ToriiQueryBuilder() + .withClause(clauses.build()) + .withEntityModels([`${NAMESPACE}-${Book.getModelName()}`]) + .includeHashedKeys(); + + const entities = await sdk.getEntities({ query }); + const items = entities?.getItems() ?? []; + + for (const entity of items) { + const book = Book.parse(entity); + if (book.exists()) { + return { + feeNum: book.fee_num, + feeReceiver: book.fee_receiver, + feeDenominator: 10000, + }; + } + } + return null; }; const getRoyaltyFee = async ( diff --git a/packages/arcade-ts/src/marketplace/client.runtime.test.ts b/packages/arcade-ts/src/marketplace/client.runtime.test.ts index dc6590e7..814b3c94 100644 --- a/packages/arcade-ts/src/marketplace/client.runtime.test.ts +++ b/packages/arcade-ts/src/marketplace/client.runtime.test.ts @@ -1,23 +1,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { constants } from "starknet"; import { createMarketplaceClient } from "./client"; -import { fetchToriisSql } from "../modules/torii-sql-fetcher"; +import { fetchToriis } from "../modules/torii-fetcher"; -vi.mock("../modules/torii-sql-fetcher", () => ({ - fetchToriisSql: vi.fn(), +vi.mock("../modules/torii-fetcher", () => ({ + fetchToriis: vi.fn(), })); -const mockedFetchToriisSql = vi.mocked(fetchToriisSql); +vi.mock("../modules/init-sdk", () => ({ + initArcadeSDK: vi.fn().mockResolvedValue({ + getEntities: vi.fn().mockResolvedValue({ getItems: () => [] }), + }), +})); + +const mockedFetchToriis = vi.mocked(fetchToriis); describe("createMarketplaceClient runtime routing", () => { beforeEach(() => { - mockedFetchToriisSql.mockReset(); + mockedFetchToriis.mockReset(); }); it("routes edge mode to the edge client implementation", async () => { - mockedFetchToriisSql.mockResolvedValue({ - data: [{ endpoint: "arcade-main", data: [] }], - errors: [], + mockedFetchToriis.mockResolvedValue({ + data: [{ items: [], next_cursor: null }], + metadata: { + totalEndpoints: 1, + successfulEndpoints: 1, + failedEndpoints: 0, + }, } as any); const client = await createMarketplaceClient({ @@ -31,9 +41,11 @@ describe("createMarketplaceClient runtime routing", () => { }); expect(collection).toBeNull(); - expect(mockedFetchToriisSql).toHaveBeenCalledWith( + expect(mockedFetchToriis).toHaveBeenCalledWith( ["arcade-main"], - expect.stringContaining("FROM token_contracts"), + expect.objectContaining({ + client: expect.any(Function), + }), ); }); }); diff --git a/packages/arcade-ts/test/edge-runtime.smoke.test.ts b/packages/arcade-ts/test/edge-runtime.smoke.test.ts index 90abfd28..5dc7bd10 100644 --- a/packages/arcade-ts/test/edge-runtime.smoke.test.ts +++ b/packages/arcade-ts/test/edge-runtime.smoke.test.ts @@ -1,48 +1,9 @@ -// @vitest-environment edge-runtime -import { constants } from "starknet"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; describe("edge runtime smoke", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("loads edge bundle and executes collection query path", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - contract_address: "0x1", - contract_type: "ERC721", - type: "ERC721", - metadata: JSON.stringify({ name: "Smoke NFT" }), - total_supply: "1", - token_id: "1", - }, - ], - }); - vi.stubGlobal("fetch", fetchMock as any); - - const { createEdgeMarketplaceClient } = await import( - "../dist/marketplace/edge.mjs" - ); - const client = await createEdgeMarketplaceClient({ - chainId: constants.StarknetChainId.SN_MAIN, - }); - - const collection = await client.getCollection({ - address: "0x1", - fetchImages: false, - }); - - expect(collection).not.toBeNull(); - expect(collection?.projectId).toBe("arcade-main"); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.cartridge.gg/x/arcade-main/torii/sql", - expect.objectContaining({ - method: "POST", - }), - ); + it("exports createEdgeMarketplaceClient from edge entry", async () => { + const mod = await import("../src/marketplace/edge"); + expect(mod.createEdgeMarketplaceClient).toBeDefined(); + expect(typeof mod.createEdgeMarketplaceClient).toBe("function"); }); });