diff --git a/packages/token-sdk/package.json b/packages/token-sdk/package.json index 08af632..7e69fba 100644 --- a/packages/token-sdk/package.json +++ b/packages/token-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@orca-so/token-sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "SPL Token Utilities", "repository": "https://github.com/orca-so/orca-sdks", "author": "Orca Foundation", @@ -24,7 +24,7 @@ "watch": "tsc -w -p src", "prepublishOnly": "yarn build", "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", - "test": "jest --detectOpenHandles" + "test": "jest --verbose --detectOpenHandles" }, "lint-staged": { "*.{ts,md}": "yarn run prettier-format" diff --git a/packages/token-sdk/src/metadata/client/metaplex-client.ts b/packages/token-sdk/src/metadata/client/metaplex-client.ts index 17abef7..a119533 100644 --- a/packages/token-sdk/src/metadata/client/metaplex-client.ts +++ b/packages/token-sdk/src/metadata/client/metaplex-client.ts @@ -4,6 +4,9 @@ import invariant from "tiny-invariant"; const METADATA_PROGRAM_ID = new PublicKey("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"); +// Metadata should be a just tiny JSON file, 2000ms should be sufficient for most cases +const DEFAULT_GET_OFF_CHAIN_METADATA_TIMEOUT_MS = 2000; + interface Creator { address: PublicKey; verified: boolean; @@ -56,7 +59,7 @@ export interface OffChainMetadata { export interface MetaplexClient { getMetadataAddress(mint: PublicKey): PublicKey; parseOnChainMetadata(mint: PublicKey, buffer: Buffer | Uint8Array): OnChainMetadata | null; - getOffChainMetadata(metadata: OnChainMetadata): Promise; + getOffChainMetadata(metadata: OnChainMetadata, timeoutMs?: number): Promise; } export class MetaplexHttpClient implements MetaplexClient { @@ -79,12 +82,12 @@ export class MetaplexHttpClient implements MetaplexClient { } } - async getOffChainMetadata(metadata: OnChainMetadata): Promise { + async getOffChainMetadata(metadata: OnChainMetadata, timeoutMs: number = DEFAULT_GET_OFF_CHAIN_METADATA_TIMEOUT_MS): Promise { try { if (metadata.uri === "") { return null; } - const response = await fetch(metadata.uri); + const response = await fetch(metadata.uri, { signal: AbortSignal.timeout(timeoutMs) }); if (response.status === 404) { return null; } diff --git a/packages/token-sdk/src/metadata/metaplex-provider.ts b/packages/token-sdk/src/metadata/metaplex-provider.ts index 2399e26..e4dbe8d 100644 --- a/packages/token-sdk/src/metadata/metaplex-provider.ts +++ b/packages/token-sdk/src/metadata/metaplex-provider.ts @@ -17,6 +17,7 @@ interface Opts { * https://github.com/metaplex-foundation/js#load */ loadImage?: boolean; + getOffChainMetadataTimeoutMs?: number; } export class MetaplexProvider implements MetadataProvider { @@ -45,7 +46,7 @@ export class MetaplexProvider implements MetadataProvider { } let image: string | undefined; if (this.opts.loadImage ?? true) { - const json = await this.client.getOffChainMetadata(meta); + const json = await this.client.getOffChainMetadata(meta, this.opts.getOffChainMetadataTimeoutMs); if (json) { image = json.image; } @@ -84,7 +85,7 @@ export class MetaplexProvider implements MetadataProvider { continue; } jsonHandlers.push(async () => { - const json = await this.client.getOffChainMetadata(meta); + const json = await this.client.getOffChainMetadata(meta, this.opts.getOffChainMetadataTimeoutMs); jsons[i] = json; }); } diff --git a/packages/token-sdk/tests/metaplex-provider.test.ts b/packages/token-sdk/tests/metaplex-provider.test.ts new file mode 100644 index 0000000..83efec1 --- /dev/null +++ b/packages/token-sdk/tests/metaplex-provider.test.ts @@ -0,0 +1,93 @@ +import { MetaplexProvider } from "../src/metadata"; +import { TokenFetcher } from "../src/fetcher"; +import { createTestContext } from "./test-context"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("metaplex-provider", () => { + // mainnet + const ctx = createTestContext("https://api.mainnet-beta.solana.com"); + + // ORCA has metadata, but not has image info, so use BONK and SHDW + const MINT_BONK = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263"; + const MINT_SHDW = "SHDWyBxihqiCj6YekG2GUr7wqKLeLAMK1gHZck9pL6y"; + + it("find ok", async () => { + const timeoutMs = 2000; + const metaplex_provider = new MetaplexProvider(ctx.connection, {loadImage: true, concurrency: 1, intervalMs: 1000, getOffChainMetadataTimeoutMs: timeoutMs}); + const fetcher = new TokenFetcher(ctx.connection).addProvider(metaplex_provider); + + const result = await fetcher.find(MINT_BONK, true); + expect(result).toBeDefined(); + expect(result.mint).toEqual(MINT_BONK); + expect(result.decimals).toEqual(5); + expect(result.name).toEqual("Bonk"); + expect(result.symbol).toEqual("Bonk"); // Their symbol is Bonk (not BONK)... + expect(result.image).toEqual("https://arweave.net/hQiPZOsRZXGXBJd_82PhVdlM_hACsT_q6wqwf5cSY7I"); + }); + + it("findMany ok", async () => { + const timeoutMs = 2000; + const metaplex_provider = new MetaplexProvider(ctx.connection, {loadImage: true, concurrency: 2, intervalMs: 1000, getOffChainMetadataTimeoutMs: timeoutMs}); + const fetcher = new TokenFetcher(ctx.connection).addProvider(metaplex_provider); + + const result = await fetcher.findMany([MINT_BONK, MINT_SHDW], true); + const resultBonk = result.get(MINT_BONK); + expect(resultBonk).toBeDefined(); + expect(resultBonk?.mint).toEqual(MINT_BONK); + expect(resultBonk?.decimals).toEqual(5); + expect(resultBonk?.name).toEqual("Bonk"); + expect(resultBonk?.symbol).toEqual("Bonk"); // Their symbol is Bonk (not BONK)... + expect(resultBonk?.image).toEqual("https://arweave.net/hQiPZOsRZXGXBJd_82PhVdlM_hACsT_q6wqwf5cSY7I"); + + const resultShdw = result.get(MINT_SHDW); + expect(resultShdw).toBeDefined(); + expect(resultShdw?.mint).toEqual(MINT_SHDW); + expect(resultShdw?.decimals).toEqual(9); + expect(resultShdw?.name).toEqual("Shadow Token"); + expect(resultShdw?.symbol).toEqual("SHDW"); + expect(resultShdw?.image).toEqual("https://shdw-drive.genesysgo.net/FDcC9gn12fFkSU2KuQYH4TUjihrZxiTodFRWNF4ns9Kt/250x250_with_padding.png"); + }); + + it("find timeout", async () => { + const timeoutMs = 1; + const metaplex_provider = new MetaplexProvider(ctx.connection, {loadImage: true, concurrency: 1, intervalMs: 1000, getOffChainMetadataTimeoutMs: timeoutMs}); + const fetcher = new TokenFetcher(ctx.connection).addProvider(metaplex_provider); + + const result = await fetcher.find(MINT_BONK, true); + expect(result).toBeDefined(); + expect(result.mint).toEqual(MINT_BONK); + expect(result.decimals).toEqual(5); + expect(result.name).toEqual("Bonk"); + expect(result.symbol).toEqual("Bonk"); + + // 1ms is too short to fetch offchain metadata, so we expect image to be undefined + expect(result.image).toBeUndefined(); + }); + + it("findMany timeout", async () => { + const timeoutMs = 1; + const metaplex_provider = new MetaplexProvider(ctx.connection, {loadImage: true, concurrency: 2, intervalMs: 1000, getOffChainMetadataTimeoutMs: timeoutMs}); + const fetcher = new TokenFetcher(ctx.connection).addProvider(metaplex_provider); + + const result = await fetcher.findMany([MINT_BONK, MINT_SHDW], true); + const resultBonk = result.get(MINT_BONK); + expect(resultBonk).toBeDefined(); + expect(resultBonk?.mint).toEqual(MINT_BONK); + expect(resultBonk?.decimals).toEqual(5); + expect(resultBonk?.name).toEqual("Bonk"); + expect(resultBonk?.symbol).toEqual("Bonk"); + + const resultShdw = result.get(MINT_SHDW); + expect(resultShdw).toBeDefined(); + expect(resultShdw?.mint).toEqual(MINT_SHDW); + expect(resultShdw?.decimals).toEqual(9); + expect(resultShdw?.name).toEqual("Shadow Token"); + expect(resultShdw?.symbol).toEqual("SHDW"); + + // 1ms is too short to fetch offchain metadata, so we expect image to be undefined + expect(resultBonk?.image).toBeUndefined(); + expect(resultShdw?.image).toBeUndefined(); + }); + +});