diff --git a/README.md b/README.md
index 1a1bd67..27c9769 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,26 @@ scarb build
snforge test
```
+## 🧰 SDK
+
+The TypeScript SDK can fetch Beast data from Summit API, fall back to Voyager, and finally to RPC using the onchain data provider. It also renders the NFT card locally.
+
+```ts
+import { createBeastSdk, generateBeastSvg, toBeastSvgInput } from "@lootsurvivor/beast-sdk";
+
+const sdk = createBeastSdk({
+ apiBaseUrl: "https://your-summit-api.example",
+ voyager: {
+ apiKey: "YOUR_VOYAGER_API_KEY",
+ },
+ // RPC defaults to https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10
+ // and the mainnet Beast data provider.
+});
+
+const { beasts } = await sdk.getAccountBeasts("0x0123...");
+const svg = beasts[0] ? generateBeastSvg(toBeastSvgInput(beasts[0])) : null;
+```
+
## 🏗️ Architecture
### Smart Contract Structure
diff --git a/sdk/.gitignore b/sdk/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/sdk/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/sdk/__tests__/client.test.ts b/sdk/__tests__/client.test.ts
new file mode 100644
index 0000000..e7cf9e3
--- /dev/null
+++ b/sdk/__tests__/client.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeStarknetAddress,
+ resolveBeastRenderData,
+ toBeastSvgInput,
+} from "../client";
+import type { BeastOnchain } from "../types";
+
+describe("client helpers", () => {
+ it("normalizes Starknet addresses", () => {
+ const normalized = normalizeStarknetAddress("0xabc");
+ expect(normalized).toBe(`0x${"0".repeat(61)}abc`);
+ });
+
+ it("resolves render data from on-chain fields", () => {
+ const beast: BeastOnchain = {
+ tokenId: 77,
+ beastId: 29,
+ prefix: 2,
+ suffix: 1,
+ level: 4,
+ health: 10,
+ shiny: 1,
+ animated: 0,
+ rank: 2,
+ };
+
+ const resolved = resolveBeastRenderData(beast);
+
+ expect(resolved.name).toBe("Dragon");
+ expect(resolved.prefixName).toBe("Apocalypse");
+ expect(resolved.suffixName).toBe("Bane");
+ expect(resolved.fullName).toBe('"Apocalypse Bane" Dragon');
+ expect(resolved.tier).toBe(1);
+ expect(resolved.type).toBe("Hunter");
+ expect(resolved.power).toBe(20);
+ expect(resolved.shiny).toBe(true);
+ expect(resolved.animated).toBe(false);
+ expect(resolved.rank).toBe(2);
+ });
+
+ it("builds SVG input from render data", () => {
+ const beast: BeastOnchain = {
+ tokenId: 1,
+ beastId: 1,
+ prefix: 0,
+ suffix: 0,
+ level: 1,
+ health: 5,
+ shiny: 0,
+ animated: 0,
+ rank: 0,
+ };
+ const resolved = resolveBeastRenderData(beast);
+ const svgInput = toBeastSvgInput(resolved);
+ expect(svgInput.beastId).toBe(1);
+ expect(svgInput.rank).toBe(0);
+ });
+});
diff --git a/sdk/__tests__/images.test.ts b/sdk/__tests__/images.test.ts
new file mode 100644
index 0000000..7e37813
--- /dev/null
+++ b/sdk/__tests__/images.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getBeastImageUrl,
+ getBeastImageUrls,
+ getBeastStaticImageUrl,
+ isLocalBeastImage,
+} from '../images';
+
+describe('Image URL generation', () => {
+ describe('getBeastImageUrl', () => {
+ it('returns correct path for static regular beast', () => {
+ expect(getBeastImageUrl(29, false, false)).toBe(
+ '/images/beasts/static/regular/dragon.png'
+ );
+ });
+
+ it('returns correct path for static shiny beast', () => {
+ expect(getBeastImageUrl(29, true, false)).toBe(
+ '/images/beasts/static/shiny/dragon.png'
+ );
+ });
+
+ it('returns correct path for animated regular beast', () => {
+ expect(getBeastImageUrl(29, false, true)).toBe(
+ '/images/beasts/animated/regular/dragon.gif'
+ );
+ });
+
+ it('returns correct path for animated shiny beast', () => {
+ expect(getBeastImageUrl(29, true, true)).toBe(
+ '/images/beasts/animated/shiny/dragon.gif'
+ );
+ });
+
+ it('handles multi-word beast names correctly', () => {
+ // Beast ID 66 is "Nemean Lion" -> should become "nemeanlion"
+ expect(getBeastImageUrl(66, false, false)).toBe(
+ '/images/beasts/static/regular/nemeanlion.png'
+ );
+ });
+
+ it('handles various beast IDs', () => {
+ // Warlock (ID 1)
+ expect(getBeastImageUrl(1, false, false)).toBe(
+ '/images/beasts/static/regular/warlock.png'
+ );
+
+ // Kraken (ID 51)
+ expect(getBeastImageUrl(51, true, true)).toBe(
+ '/images/beasts/animated/shiny/kraken.gif'
+ );
+
+ // Fairy (ID 21)
+ expect(getBeastImageUrl(21, false, false)).toBe(
+ '/images/beasts/static/regular/fairy.png'
+ );
+ });
+
+ it('handles unknown beast ID gracefully', () => {
+ expect(getBeastImageUrl(0, false, false)).toBe(
+ '/images/beasts/static/regular/unknown.png'
+ );
+ expect(getBeastImageUrl(100, false, false)).toBe(
+ '/images/beasts/static/regular/unknown.png'
+ );
+ });
+ });
+
+ describe('getBeastImageUrls', () => {
+ it('returns both imageUrl and imageSmallUrl', () => {
+ const result = getBeastImageUrls(29, false, false);
+ expect(result.imageUrl).toBe('/images/beasts/static/regular/dragon.png');
+ expect(result.imageSmallUrl).toBe('/images/beasts/static/regular/dragon.png');
+ });
+
+ it('handles shiny and animated flags', () => {
+ const result = getBeastImageUrls(51, true, true);
+ expect(result.imageUrl).toBe('/images/beasts/animated/shiny/kraken.gif');
+ expect(result.imageSmallUrl).toBe('/images/beasts/animated/shiny/kraken.gif');
+ });
+ });
+
+ describe('getBeastStaticImageUrl', () => {
+ it('always returns PNG static image', () => {
+ expect(getBeastStaticImageUrl(29, false)).toBe(
+ '/images/beasts/static/regular/dragon.png'
+ );
+ expect(getBeastStaticImageUrl(29, true)).toBe(
+ '/images/beasts/static/shiny/dragon.png'
+ );
+ });
+ });
+
+ describe('isLocalBeastImage', () => {
+ it('returns true for local beast image URLs', () => {
+ expect(isLocalBeastImage('/images/beasts/static/regular/dragon.png')).toBe(true);
+ expect(isLocalBeastImage('/images/beasts/animated/shiny/kraken.gif')).toBe(true);
+ });
+
+ it('returns false for external URLs', () => {
+ expect(isLocalBeastImage('https://example.com/image.png')).toBe(false);
+ expect(isLocalBeastImage('/images/other/image.png')).toBe(false);
+ });
+ });
+});
diff --git a/sdk/__tests__/index.test.ts b/sdk/__tests__/index.test.ts
new file mode 100644
index 0000000..1eac0c8
--- /dev/null
+++ b/sdk/__tests__/index.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getFullBeastName,
+ isGenesisBeast,
+ getTierColor,
+ calculatePower,
+ getBeastInfo,
+} from '../index';
+
+describe('Beast SDK utility functions', () => {
+ describe('getFullBeastName', () => {
+ it('returns beast name with prefix and suffix', () => {
+ expect(getFullBeastName(29, 'Apocalypse', 'Bane')).toBe(
+ '"Apocalypse Bane" Dragon'
+ );
+ });
+
+ it('returns beast name with only prefix', () => {
+ expect(getFullBeastName(29, 'Apocalypse', null)).toBe(
+ '"Apocalypse" Dragon'
+ );
+ });
+
+ it('returns beast name with only suffix', () => {
+ expect(getFullBeastName(29, null, 'Bane')).toBe('"Bane" Dragon');
+ });
+
+ it('returns just beast name when no prefix or suffix', () => {
+ expect(getFullBeastName(29, null, null)).toBe('Dragon');
+ });
+
+ it('handles unknown beast ID', () => {
+ expect(getFullBeastName(0, null, null)).toBe('Unknown');
+ });
+ });
+
+ describe('isGenesisBeast', () => {
+ it('returns true for token IDs 1-75', () => {
+ expect(isGenesisBeast(1)).toBe(true);
+ expect(isGenesisBeast(75)).toBe(true);
+ expect(isGenesisBeast(38)).toBe(true);
+ });
+
+ it('returns false for token IDs above 75', () => {
+ expect(isGenesisBeast(76)).toBe(false);
+ expect(isGenesisBeast(1000)).toBe(false);
+ expect(isGenesisBeast(99999)).toBe(false);
+ });
+
+ it('returns false for token ID 0', () => {
+ expect(isGenesisBeast(0)).toBe(false);
+ });
+
+ it('handles string token IDs', () => {
+ expect(isGenesisBeast('1')).toBe(true);
+ expect(isGenesisBeast('75')).toBe(true);
+ expect(isGenesisBeast('76')).toBe(false);
+ expect(isGenesisBeast('1001')).toBe(false);
+ });
+ });
+
+ describe('getTierColor', () => {
+ it('returns orange for tier 1', () => {
+ expect(getTierColor(1)).toBe('#ff8800');
+ });
+
+ it('returns purple for tier 2', () => {
+ expect(getTierColor(2)).toBe('#8c00bf');
+ });
+
+ it('returns blue for tier 3', () => {
+ expect(getTierColor(3)).toBe('#0066ff');
+ });
+
+ it('returns green for tier 4', () => {
+ expect(getTierColor(4)).toBe('#00cc00');
+ });
+
+ it('returns gray for tier 5', () => {
+ expect(getTierColor(5)).toBe('#cccccc');
+ });
+
+ it('returns gray for invalid tier', () => {
+ expect(getTierColor(0)).toBe('#cccccc');
+ expect(getTierColor(6)).toBe('#cccccc');
+ });
+ });
+
+ describe('calculatePower', () => {
+ it('calculates power correctly for tier 1', () => {
+ // Power = (6 - tier) * level = 5 * level
+ expect(calculatePower(1, 10)).toBe(50);
+ expect(calculatePower(1, 20)).toBe(100);
+ });
+
+ it('calculates power correctly for tier 5', () => {
+ // Power = (6 - 5) * level = 1 * level
+ expect(calculatePower(5, 10)).toBe(10);
+ expect(calculatePower(5, 20)).toBe(20);
+ });
+
+ it('calculates power for various tiers', () => {
+ expect(calculatePower(2, 10)).toBe(40); // (6-2) * 10 = 40
+ expect(calculatePower(3, 10)).toBe(30); // (6-3) * 10 = 30
+ expect(calculatePower(4, 10)).toBe(20); // (6-4) * 10 = 20
+ });
+ });
+
+ describe('getBeastInfo', () => {
+ it('returns complete info for valid beast ID', () => {
+ const info = getBeastInfo(29);
+ expect(info.name).toBe('Dragon');
+ expect(info.tier).toBe(1);
+ expect(info.type).toBe('Hunter');
+ expect(info.tierColor).toBe('#ff8800');
+ });
+
+ it('returns info for Magic beast', () => {
+ const info = getBeastInfo(1);
+ expect(info.name).toBe('Warlock');
+ expect(info.tier).toBe(1);
+ expect(info.type).toBe('Magic');
+ });
+
+ it('returns info for Brute beast', () => {
+ const info = getBeastInfo(51);
+ expect(info.name).toBe('Kraken');
+ expect(info.tier).toBe(1);
+ expect(info.type).toBe('Brute');
+ });
+
+ it('returns info for T5 beast', () => {
+ const info = getBeastInfo(21);
+ expect(info.name).toBe('Fairy');
+ expect(info.tier).toBe(5);
+ expect(info.type).toBe('Magic');
+ expect(info.tierColor).toBe('#cccccc');
+ });
+ });
+});
diff --git a/sdk/__tests__/lookups.test.ts b/sdk/__tests__/lookups.test.ts
new file mode 100644
index 0000000..e2ac7b4
--- /dev/null
+++ b/sdk/__tests__/lookups.test.ts
@@ -0,0 +1,188 @@
+import { describe, it, expect } from 'vitest';
+import {
+ BEAST_NAMES,
+ BEAST_TIERS,
+ BEAST_TYPES,
+ ITEM_NAME_PREFIXES,
+ ITEM_NAME_SUFFIXES,
+ getBeastName,
+ getBeastTier,
+ getBeastType,
+ getPrefix,
+ getSuffix,
+} from '../lookups';
+
+describe('Beast lookup tables', () => {
+ describe('BEAST_NAMES', () => {
+ it('has 75 beast names', () => {
+ expect(Object.keys(BEAST_NAMES).length).toBe(75);
+ });
+
+ it('has correct names for T1 Magic beasts', () => {
+ expect(BEAST_NAMES[1]).toBe('Warlock');
+ expect(BEAST_NAMES[2]).toBe('Typhon');
+ expect(BEAST_NAMES[3]).toBe('Jiangshi');
+ expect(BEAST_NAMES[4]).toBe('Anansi');
+ expect(BEAST_NAMES[5]).toBe('Basilisk');
+ });
+
+ it('has correct names for T1 Hunter beasts', () => {
+ expect(BEAST_NAMES[26]).toBe('Griffin');
+ expect(BEAST_NAMES[27]).toBe('Manticore');
+ expect(BEAST_NAMES[28]).toBe('Phoenix');
+ expect(BEAST_NAMES[29]).toBe('Dragon');
+ expect(BEAST_NAMES[30]).toBe('Minotaur');
+ });
+
+ it('has correct names for T1 Brute beasts', () => {
+ expect(BEAST_NAMES[51]).toBe('Kraken');
+ expect(BEAST_NAMES[52]).toBe('Colossus');
+ expect(BEAST_NAMES[53]).toBe('Balrog');
+ expect(BEAST_NAMES[54]).toBe('Leviathan');
+ expect(BEAST_NAMES[55]).toBe('Tarrasque');
+ });
+ });
+
+ describe('BEAST_TIERS', () => {
+ it('has 75 tier entries', () => {
+ expect(Object.keys(BEAST_TIERS).length).toBe(75);
+ });
+
+ it('returns tier 1 for T1 beasts (IDs 1-5, 26-30, 51-55)', () => {
+ // Magic T1
+ expect(BEAST_TIERS[1]).toBe(1);
+ expect(BEAST_TIERS[5]).toBe(1);
+ // Hunter T1
+ expect(BEAST_TIERS[26]).toBe(1);
+ expect(BEAST_TIERS[30]).toBe(1);
+ // Brute T1
+ expect(BEAST_TIERS[51]).toBe(1);
+ expect(BEAST_TIERS[55]).toBe(1);
+ });
+
+ it('returns tier 5 for T5 beasts (IDs 21-25, 46-50, 71-75)', () => {
+ // Magic T5
+ expect(BEAST_TIERS[21]).toBe(5);
+ expect(BEAST_TIERS[25]).toBe(5);
+ // Hunter T5
+ expect(BEAST_TIERS[46]).toBe(5);
+ expect(BEAST_TIERS[50]).toBe(5);
+ // Brute T5
+ expect(BEAST_TIERS[71]).toBe(5);
+ expect(BEAST_TIERS[75]).toBe(5);
+ });
+ });
+
+ describe('BEAST_TYPES', () => {
+ it('has 75 type entries', () => {
+ expect(Object.keys(BEAST_TYPES).length).toBe(75);
+ });
+
+ it('returns Magic for IDs 1-25', () => {
+ expect(BEAST_TYPES[1]).toBe('Magic');
+ expect(BEAST_TYPES[13]).toBe('Magic');
+ expect(BEAST_TYPES[25]).toBe('Magic');
+ });
+
+ it('returns Hunter for IDs 26-50', () => {
+ expect(BEAST_TYPES[26]).toBe('Hunter');
+ expect(BEAST_TYPES[38]).toBe('Hunter');
+ expect(BEAST_TYPES[50]).toBe('Hunter');
+ });
+
+ it('returns Brute for IDs 51-75', () => {
+ expect(BEAST_TYPES[51]).toBe('Brute');
+ expect(BEAST_TYPES[63]).toBe('Brute');
+ expect(BEAST_TYPES[75]).toBe('Brute');
+ });
+ });
+
+ describe('ITEM_NAME_PREFIXES', () => {
+ it('has 69 prefix names', () => {
+ expect(Object.keys(ITEM_NAME_PREFIXES).length).toBe(69);
+ });
+
+ it('has correct prefix values', () => {
+ expect(ITEM_NAME_PREFIXES[1]).toBe('Agony');
+ expect(ITEM_NAME_PREFIXES[2]).toBe('Apocalypse');
+ expect(ITEM_NAME_PREFIXES[69]).toBe('Shimmering');
+ });
+ });
+
+ describe('ITEM_NAME_SUFFIXES', () => {
+ it('has 18 suffix names', () => {
+ expect(Object.keys(ITEM_NAME_SUFFIXES).length).toBe(18);
+ });
+
+ it('has correct suffix values', () => {
+ expect(ITEM_NAME_SUFFIXES[1]).toBe('Bane');
+ expect(ITEM_NAME_SUFFIXES[2]).toBe('Root');
+ expect(ITEM_NAME_SUFFIXES[18]).toBe('Moon');
+ });
+ });
+});
+
+describe('Lookup functions', () => {
+ describe('getBeastName', () => {
+ it('returns beast name for valid ID', () => {
+ expect(getBeastName(1)).toBe('Warlock');
+ expect(getBeastName(29)).toBe('Dragon');
+ expect(getBeastName(51)).toBe('Kraken');
+ });
+
+ it('returns "Unknown" for invalid ID', () => {
+ expect(getBeastName(0)).toBe('Unknown');
+ expect(getBeastName(76)).toBe('Unknown');
+ expect(getBeastName(-1)).toBe('Unknown');
+ });
+ });
+
+ describe('getBeastTier', () => {
+ it('returns tier for valid ID', () => {
+ expect(getBeastTier(1)).toBe(1);
+ expect(getBeastTier(21)).toBe(5);
+ });
+
+ it('returns 5 (default) for invalid ID', () => {
+ expect(getBeastTier(0)).toBe(5);
+ expect(getBeastTier(100)).toBe(5);
+ });
+ });
+
+ describe('getBeastType', () => {
+ it('returns type for valid ID', () => {
+ expect(getBeastType(1)).toBe('Magic');
+ expect(getBeastType(26)).toBe('Hunter');
+ expect(getBeastType(51)).toBe('Brute');
+ });
+
+ it('returns "Unknown" for invalid ID', () => {
+ expect(getBeastType(0)).toBe('Unknown');
+ expect(getBeastType(100)).toBe('Unknown');
+ });
+ });
+
+ describe('getPrefix', () => {
+ it('returns prefix for valid ID', () => {
+ expect(getPrefix(1)).toBe('Agony');
+ expect(getPrefix(2)).toBe('Apocalypse');
+ });
+
+ it('returns empty string for invalid ID', () => {
+ expect(getPrefix(0)).toBe('');
+ expect(getPrefix(100)).toBe('');
+ });
+ });
+
+ describe('getSuffix', () => {
+ it('returns suffix for valid ID', () => {
+ expect(getSuffix(1)).toBe('Bane');
+ expect(getSuffix(2)).toBe('Root');
+ });
+
+ it('returns empty string for invalid ID', () => {
+ expect(getSuffix(0)).toBe('');
+ expect(getSuffix(100)).toBe('');
+ });
+ });
+});
diff --git a/sdk/__tests__/metadata.test.ts b/sdk/__tests__/metadata.test.ts
new file mode 100644
index 0000000..becd353
--- /dev/null
+++ b/sdk/__tests__/metadata.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseTokenUri,
+ decodeSvgDataUri,
+ parseMetadataAttributes,
+ buildTokenUriFromJsonString,
+ buildTokenUri,
+ encodeSvgDataUri,
+ validateMetadataJson,
+ parseTokenUriDetailed,
+ parseTokenUriSafe,
+} from "../metadata";
+
+describe("metadata helpers", () => {
+ it("parses token URI with literal newlines in description", () => {
+ const rawJson = `{"name":"Beast","description":"Line1
+Line2","image":"data:image/svg+xml;base64,PHN2Zy8+","attributes":[{"trait_type":"Token ID","value":"1"},{"trait_type":"Beast ID","value":"3"},{"trait_type":"Beast","value":"Jiangshi"},{"trait_type":"Type","value":"Magic"},{"trait_type":"Tier","value":"1"},{"trait_type":"Level","value":"42"},{"trait_type":"Health","value":"1337"},{"trait_type":"Power","value":"210"},{"trait_type":"Rank","value":"5"},{"trait_type":"Shiny","value":"0"},{"trait_type":"Animated","value":"1"},{"trait_type":"Genesis","value":"1"}]}`;
+ const tokenUri = `data:application/json;base64,${Buffer.from(rawJson, "utf8").toString("base64")}`;
+
+ const metadata = parseTokenUri(tokenUri);
+ expect(metadata.name).toBe("Beast");
+ expect(metadata.description.includes("Line1")).toBe(true);
+ expect(metadata.description.includes("Line2")).toBe(true);
+ });
+
+ it("decodes svg data URIs", () => {
+ const svg = "";
+ const dataUri = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
+ expect(decodeSvgDataUri(dataUri)).toBe(svg);
+ });
+
+ it("encodes svg data URIs", () => {
+ const svg = "";
+ const dataUri = encodeSvgDataUri(svg);
+ expect(decodeSvgDataUri(dataUri)).toBe(svg);
+ });
+
+ it("parses typed metadata attributes", () => {
+ const rawJson = `{"name":"Beast","description":"Desc","image":"data:image/svg+xml;base64,PHN2Zy8+","attributes":[{"trait_type":"Token ID","value":"1"},{"trait_type":"Beast ID","value":"3"},{"trait_type":"Beast","value":"Jiangshi"},{"trait_type":"Type","value":"Magic"},{"trait_type":"Tier","value":"1"},{"trait_type":"Level","value":"42"},{"trait_type":"Health","value":"1337"},{"trait_type":"Power","value":"210"},{"trait_type":"Rank","value":"5"},{"trait_type":"Shiny","value":"0"},{"trait_type":"Animated","value":"1"},{"trait_type":"Genesis","value":"1"}]}`;
+ const tokenUri = `data:application/json;base64,${Buffer.from(rawJson, "utf8").toString("base64")}`;
+ const metadata = parseTokenUri(tokenUri);
+ const parsed = parseMetadataAttributes(metadata);
+
+ expect(parsed.tokenId).toBe(1);
+ expect(parsed.beastId).toBe(3);
+ expect(parsed.beast).toBe("Jiangshi");
+ expect(parsed.type).toBe("Magic");
+ expect(parsed.tier).toBe(1);
+ expect(parsed.level).toBe(42);
+ expect(parsed.health).toBe(1337);
+ expect(parsed.power).toBe(210);
+ expect(parsed.rank).toBe(5);
+ expect(parsed.shiny).toBe(0);
+ expect(parsed.animated).toBe(1);
+ expect(parsed.genesis).toBe(1);
+ });
+
+ it("builds token URIs from raw JSON strings", () => {
+ const rawJson = `{"name":"Beast","description":"Line1
+Line2","image":"data:image/svg+xml;base64,PHN2Zy8+","attributes":[]}`;
+ const tokenUri = buildTokenUriFromJsonString(rawJson);
+ const parsed = parseTokenUri(tokenUri);
+ expect(parsed.description.includes("Line1")).toBe(true);
+ expect(parsed.description.includes("Line2")).toBe(true);
+ });
+
+ it("builds token URIs from JSON objects", () => {
+ const metadata = {
+ name: "Beast",
+ description: "Desc",
+ image: "data:image/svg+xml;base64,PHN2Zy8+",
+ attributes: [],
+ };
+ const tokenUri = buildTokenUri(metadata);
+ const parsed = parseTokenUri(tokenUri);
+ expect(parsed.name).toBe("Beast");
+ });
+
+ it("validates metadata JSON shape", () => {
+ const ok = validateMetadataJson({
+ name: "Beast",
+ description: "Desc",
+ image: "data:image/svg+xml;base64,PHN2Zy8+",
+ attributes: [{ trait_type: "Token ID", value: "1" }],
+ });
+ expect(ok.ok).toBe(true);
+
+ const bad = validateMetadataJson({
+ name: "Beast",
+ description: "Desc",
+ image: "data:image/svg+xml;base64,PHN2Zy8+",
+ attributes: [{ trait_type: "Token ID", value: 1 }],
+ });
+ expect(bad.ok).toBe(false);
+ });
+
+ it("parses token URIs into detailed views", () => {
+ const rawJson = `{"name":"Beast","description":"Desc","image":"data:image/svg+xml;base64,PHN2Zy8+","attributes":[{"trait_type":"Token ID","value":"1"}]}`;
+ const tokenUri = buildTokenUriFromJsonString(rawJson);
+ const detailed = parseTokenUriDetailed(tokenUri, { decodeSvg: true });
+ expect(detailed.attributes.tokenId).toBe(1);
+ expect(detailed.imageSvg).toBe("");
+ });
+
+ it("safe-parses invalid token URIs", () => {
+ const tokenUri = "data:application/json;base64,invalid";
+ const result = parseTokenUriSafe(tokenUri, { validate: true });
+ expect(result.ok).toBe(false);
+ expect(result.error).toBeDefined();
+ });
+});
diff --git a/sdk/__tests__/svg.test.ts b/sdk/__tests__/svg.test.ts
new file mode 100644
index 0000000..f0cbed2
--- /dev/null
+++ b/sdk/__tests__/svg.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest';
+import { generateBeastSvg } from '../svg';
+
+describe('SVG generation', () => {
+ it('uses PNG data URI by default', () => {
+ const svg = generateBeastSvg({
+ beastId: 1,
+ prefix: null,
+ suffix: null,
+ tier: 1,
+ level: 1,
+ health: 1,
+ power: 1,
+ shiny: false,
+ rank: null,
+ });
+
+ expect(svg.includes('data:image/png;base64,')).toBe(true);
+ });
+
+ it('uses GIF data URI when animated is true', () => {
+ const svg = generateBeastSvg({
+ beastId: 1,
+ prefix: null,
+ suffix: null,
+ tier: 1,
+ level: 1,
+ health: 1,
+ power: 1,
+ shiny: false,
+ rank: null,
+ animated: true,
+ });
+
+ expect(svg.includes('data:image/gif;base64,')).toBe(true);
+ });
+});
diff --git a/sdk/__tests__/utils.test.ts b/sdk/__tests__/utils.test.ts
new file mode 100644
index 0000000..a9820a1
--- /dev/null
+++ b/sdk/__tests__/utils.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect } from "vitest";
+import { calculatePowerClamped, isHigherRank } from "../utils";
+
+describe("utility helpers", () => {
+ it("clamps power to u16 max", () => {
+ // Tier 1 => multiplier 5, max level before clamp = floor(65535 / 5) = 13107
+ expect(calculatePowerClamped(1, 13107)).toBe(65535);
+ expect(calculatePowerClamped(1, 13108)).toBe(65535);
+ });
+
+ it("calculates unclamped power correctly", () => {
+ expect(calculatePowerClamped(2, 10)).toBe(40); // (6-2)*10
+ expect(calculatePowerClamped(5, 10)).toBe(10); // (6-5)*10
+ });
+
+ it("compares rank by power then health", () => {
+ expect(isHigherRank(50, 10, 40, 999)).toBe(true);
+ expect(isHigherRank(50, 10, 50, 9)).toBe(true);
+ expect(isHigherRank(50, 10, 50, 10)).toBe(false);
+ expect(isHigherRank(40, 100, 50, 1)).toBe(false);
+ });
+});
diff --git a/sdk/__tests__/validation.test.ts b/sdk/__tests__/validation.test.ts
new file mode 100644
index 0000000..8ab5a4c
--- /dev/null
+++ b/sdk/__tests__/validation.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from "vitest";
+import {
+ validateBeastId,
+ validateBeastAttributes,
+ validateBeastInput,
+ createGenesisBeast,
+ GENESIS_DEFAULTS,
+} from "../validation";
+
+describe("validation helpers", () => {
+ it("validates beast IDs", () => {
+ expect(validateBeastId(1).ok).toBe(true);
+ expect(validateBeastId(75).ok).toBe(true);
+ expect(validateBeastId(0).ok).toBe(false);
+ expect(validateBeastId(76).ok).toBe(false);
+ });
+
+ it("validates beast attributes", () => {
+ expect(validateBeastAttributes(0, 0, 0, 0).ok).toBe(true);
+ expect(validateBeastAttributes(69, 18, 1, 1).ok).toBe(true);
+ expect(validateBeastAttributes(70, 0, 0, 0).ok).toBe(false);
+ expect(validateBeastAttributes(0, 19, 0, 0).ok).toBe(false);
+ expect(validateBeastAttributes(0, 0, 2, 0).ok).toBe(false);
+ expect(validateBeastAttributes(0, 0, 0, 2).ok).toBe(false);
+ expect(validateBeastAttributes(-1, 0, 0, 0).ok).toBe(false);
+ });
+
+ it("validates full beast input", () => {
+ const result = validateBeastInput({
+ beastId: 10,
+ prefix: 1,
+ suffix: 2,
+ level: 10,
+ health: 100,
+ shiny: 1,
+ animated: 0,
+ });
+ expect(result.ok).toBe(true);
+ });
+
+ it("creates genesis beast defaults", () => {
+ const genesis = createGenesisBeast(5);
+ expect(genesis.beastId).toBe(5);
+ expect(genesis.prefix).toBe(GENESIS_DEFAULTS.prefix);
+ expect(genesis.suffix).toBe(GENESIS_DEFAULTS.suffix);
+ expect(genesis.level).toBe(GENESIS_DEFAULTS.level);
+ expect(genesis.health).toBe(GENESIS_DEFAULTS.health);
+ expect(genesis.shiny).toBe(GENESIS_DEFAULTS.shiny);
+ expect(genesis.animated).toBe(GENESIS_DEFAULTS.animated);
+ });
+});
diff --git a/sdk/client.ts b/sdk/client.ts
new file mode 100644
index 0000000..364d052
--- /dev/null
+++ b/sdk/client.ts
@@ -0,0 +1,1060 @@
+/**
+ * Beast SDK client
+ *
+ * Provides data fetching from Summit API, Voyager API, or RPC fallback.
+ */
+
+import {
+ getBeastName,
+ getBeastTier,
+ getBeastType,
+ getBeastIdByName,
+ getPrefix,
+ getPrefixIdByName,
+ getSuffix,
+ getSuffixIdByName,
+} from "./lookups";
+import { calculatePower } from "./utils";
+import type {
+ BeastOnchain,
+ BeastRenderData,
+ BeastFetchResult,
+ SummitBeastListResponse,
+ SummitBeastRow,
+} from "./types";
+import type { BeastSvgInput } from "./svg";
+
+const DEFAULT_RPC_URL = "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_10";
+const DEFAULT_DATA_PROVIDER_ADDRESS =
+ "0x00a23b848b296094b592b6a1944fa72de64fcd620b33403b22a2414a76b0a964";
+const DEFAULT_BEAST_NFT_ADDRESS =
+ "0x046da8955829adf2bda310099a0063451923f02e648cf25a1203aac6335cf0e4";
+
+const DEFAULT_VOYAGER_API_URL = "https://api.voyager.online/beta";
+
+const DEFAULT_OWNERSHIP_BATCHES = 8;
+const DEFAULT_RPC_TIMEOUT_MS = 300_000;
+const DEFAULT_RPC_CONCURRENCY = 8;
+const DEFAULT_API_TIMEOUT_MS = 30_000;
+const DEFAULT_PAGE_SIZE = 100;
+
+const TOTAL_SUPPLY_SELECTOR =
+ "0x01557182e4359a1f0c6301278e8f5b35a776ab58d39892581e357578fb287836";
+const GET_BEAST_OWNERS_RANGE_SELECTOR =
+ "0x01ce5018d4217224edbb53a2cb09e8aac71c3bf9a108f07e4133207bf2161a45";
+const GET_BEAST_SELECTOR =
+ "0x0385b69551f247794fe651459651cdabc76b6cdf4abacafb5b28ceb3b1ac2e98";
+const GET_BEAST_RANK_SELECTOR =
+ "0x021d56a4b44096b9487419b2c0f193d1813e09b7737e8f4a1a0de093224281e4";
+
+export interface BeastSdkConfig {
+ /** Base URL for Summit API (e.g. https://api.example.com). */
+ apiBaseUrl?: string;
+ /** Timeout for Summit API requests (ms). */
+ apiRequestTimeoutMs?: number;
+ /** Page size for Summit API /beasts/all (max 100). */
+ apiPageSize?: number;
+ /** Voyager API settings (fallback between API and RPC). */
+ voyager?: {
+ apiUrl?: string;
+ apiKey?: string;
+ requestTimeoutMs?: number;
+ pageSize?: number;
+ };
+ /** RPC settings (final fallback). */
+ rpc?: {
+ url?: string;
+ dataProviderAddress?: string;
+ beastNftAddress?: string;
+ ownershipBatches?: number;
+ requestTimeoutMs?: number;
+ concurrency?: number;
+ };
+ /** Custom fetch implementation (defaults to global fetch). */
+ fetch?: typeof fetch;
+}
+
+export interface BeastSdkClient {
+ getAccountBeasts(
+ owner: string,
+ options?: GetAccountBeastsOptions
+ ): Promise;
+ getAccountTokenIds(
+ owner: string,
+ options?: GetAccountTokenIdsOptions
+ ): Promise;
+ getBeastsByTokenIds(
+ tokenIds: number[],
+ options?: GetBeastsByTokenIdsOptions
+ ): Promise;
+}
+
+export interface GetAccountBeastsOptions {
+ includeRank?: boolean;
+}
+
+export interface GetAccountTokenIdsOptions {
+ /**
+ * @deprecated Voyager is always attempted when configured.
+ */
+ allowVoyagerFallback?: boolean;
+}
+
+export interface GetBeastsByTokenIdsOptions {
+ includeRank?: boolean;
+}
+
+interface VoyagerAttribute {
+ trait_type: string;
+ value: string;
+}
+
+interface VoyagerNftItem {
+ tokenId: string;
+ attributes?: VoyagerAttribute[] | null;
+}
+
+interface VoyagerResponse {
+ items: VoyagerNftItem[];
+ pagination?: {
+ next?: string | null;
+ };
+}
+
+export function createBeastSdk(config: BeastSdkConfig = {}): BeastSdkClient {
+ const fetcher = config.fetch ?? globalThis.fetch;
+ if (!fetcher) {
+ throw new Error("fetch is not available; provide BeastSdkConfig.fetch");
+ }
+
+ const apiBaseUrl = config.apiBaseUrl?.replace(/\/$/, "");
+ const apiTimeoutMs = config.apiRequestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS;
+ const apiPageSize = Math.min(
+ Math.max(1, config.apiPageSize ?? DEFAULT_PAGE_SIZE),
+ DEFAULT_PAGE_SIZE
+ );
+
+ const voyagerApiUrl = config.voyager?.apiUrl ?? DEFAULT_VOYAGER_API_URL;
+ const voyagerApiKey = config.voyager?.apiKey;
+ const voyagerTimeoutMs =
+ config.voyager?.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS;
+ const voyagerPageSize = Math.min(
+ Math.max(1, config.voyager?.pageSize ?? DEFAULT_PAGE_SIZE),
+ DEFAULT_PAGE_SIZE
+ );
+
+ const rpcUrl = config.rpc?.url ?? DEFAULT_RPC_URL;
+ const dataProviderAddress =
+ config.rpc?.dataProviderAddress ?? DEFAULT_DATA_PROVIDER_ADDRESS;
+ const beastNftAddress =
+ config.rpc?.beastNftAddress ?? DEFAULT_BEAST_NFT_ADDRESS;
+ const ownershipBatches =
+ Math.max(1, config.rpc?.ownershipBatches ?? DEFAULT_OWNERSHIP_BATCHES);
+ const rpcTimeoutMs = config.rpc?.requestTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
+ const rpcConcurrency = Math.max(
+ 1,
+ config.rpc?.concurrency ?? DEFAULT_RPC_CONCURRENCY
+ );
+
+ async function getAccountBeasts(
+ owner: string,
+ options?: GetAccountBeastsOptions
+ ): Promise {
+ const includeRank = options?.includeRank ?? true;
+ const normalizedOwner = normalizeStarknetAddress(owner);
+
+ if (apiBaseUrl) {
+ try {
+ const beasts = await fetchSummitBeasts(
+ fetcher,
+ apiBaseUrl,
+ normalizedOwner,
+ apiPageSize,
+ apiTimeoutMs
+ );
+ const ranked = includeRank
+ ? await fillMissingRanks(fetcher, beasts)
+ : beasts;
+ return {
+ beasts: resolveBeastRenderDataList(ranked),
+ source: "summit-api",
+ };
+ } catch (error) {
+ // fall through to Voyager or RPC
+ void error;
+ }
+ }
+
+ if (voyagerApiKey) {
+ try {
+ const beasts = await fetchVoyagerBeasts(
+ fetcher,
+ voyagerApiUrl,
+ voyagerApiKey,
+ beastNftAddress,
+ normalizedOwner,
+ voyagerPageSize,
+ voyagerTimeoutMs
+ );
+ const ranked = includeRank
+ ? await fillMissingRanks(fetcher, beasts)
+ : beasts;
+ return {
+ beasts: resolveBeastRenderDataList(ranked),
+ source: "voyager",
+ };
+ } catch (error) {
+ // fall through to RPC
+ void error;
+ }
+ }
+
+ const tokenIds = await getTokenIdsByOwnerViaRpc(
+ fetcher,
+ rpcUrl,
+ beastNftAddress,
+ dataProviderAddress,
+ normalizedOwner,
+ ownershipBatches,
+ rpcTimeoutMs
+ );
+ const beasts = await getBeastsByTokenIdsViaRpc(
+ fetcher,
+ rpcUrl,
+ beastNftAddress,
+ tokenIds,
+ rpcTimeoutMs,
+ rpcConcurrency
+ );
+ const ranked = includeRank
+ ? await fillMissingRanks(fetcher, beasts)
+ : beasts;
+ return { beasts: resolveBeastRenderDataList(ranked), source: "rpc" };
+ }
+
+ async function getAccountTokenIds(
+ owner: string,
+ options?: GetAccountTokenIdsOptions
+ ): Promise {
+ const normalizedOwner = normalizeStarknetAddress(owner);
+
+ if (apiBaseUrl) {
+ try {
+ const tokenIds = await fetchSummitTokenIds(
+ fetcher,
+ apiBaseUrl,
+ normalizedOwner,
+ apiPageSize,
+ apiTimeoutMs
+ );
+ return tokenIds;
+ } catch (error) {
+ void error;
+ }
+ }
+
+ if (voyagerApiKey) {
+ try {
+ const tokenIds = await fetchVoyagerTokenIds(
+ fetcher,
+ voyagerApiUrl,
+ voyagerApiKey,
+ beastNftAddress,
+ normalizedOwner,
+ voyagerPageSize,
+ voyagerTimeoutMs
+ );
+ return tokenIds;
+ } catch (error) {
+ void error;
+ }
+ }
+
+ return getTokenIdsByOwnerViaRpc(
+ fetcher,
+ rpcUrl,
+ beastNftAddress,
+ dataProviderAddress,
+ normalizedOwner,
+ ownershipBatches,
+ rpcTimeoutMs
+ );
+ }
+
+ async function getBeastsByTokenIds(
+ tokenIds: number[],
+ options?: GetBeastsByTokenIdsOptions
+ ): Promise {
+ const includeRank = options?.includeRank ?? true;
+
+ const beasts = await getBeastsByTokenIdsViaRpc(
+ fetcher,
+ rpcUrl,
+ beastNftAddress,
+ tokenIds,
+ rpcTimeoutMs,
+ rpcConcurrency
+ );
+ const ranked = includeRank
+ ? await fillMissingRanks(fetcher, beasts)
+ : beasts;
+ return { beasts: resolveBeastRenderDataList(ranked), source: "rpc" };
+ }
+
+ async function fillMissingRanks(
+ fetchImpl: typeof fetch,
+ beasts: BeastOnchain[]
+ ): Promise {
+ const missing = beasts.filter(
+ (beast) => beast.rank == null && !isGenesisToken(beast.tokenId)
+ );
+
+ if (missing.length === 0) {
+ return beasts.map((beast) => ({
+ ...beast,
+ rank: beast.rank ?? (isGenesisToken(beast.tokenId) ? 0 : null),
+ }));
+ }
+
+ const ranks = await getBeastRanksViaRpc(
+ fetchImpl,
+ rpcUrl,
+ beastNftAddress,
+ missing.map((beast) => beast.tokenId),
+ rpcTimeoutMs,
+ rpcConcurrency
+ );
+
+ return beasts.map((beast) => ({
+ ...beast,
+ rank:
+ beast.rank ??
+ ranks.get(beast.tokenId) ??
+ (isGenesisToken(beast.tokenId) ? 0 : null),
+ }));
+ }
+
+ return {
+ getAccountBeasts,
+ getAccountTokenIds,
+ getBeastsByTokenIds,
+ };
+}
+
+export function resolveBeastRenderData(beast: BeastOnchain): BeastRenderData {
+ const name = getBeastName(beast.beastId);
+ const tier = getBeastTier(beast.beastId);
+ const type = getBeastType(beast.beastId) as BeastRenderData["type"];
+ const prefixName = beast.prefix > 0 ? getPrefix(beast.prefix) : null;
+ const suffixName = beast.suffix > 0 ? getSuffix(beast.suffix) : null;
+ const fullName = formatFullName(name, prefixName, suffixName);
+ const power = calculatePower(tier, beast.level);
+ const rank =
+ beast.rank ?? (isGenesisToken(beast.tokenId) ? 0 : null);
+
+ return {
+ tokenId: beast.tokenId,
+ beastId: beast.beastId,
+ prefix: beast.prefix,
+ suffix: beast.suffix,
+ level: beast.level,
+ health: beast.health,
+ shiny: beast.shiny === 1,
+ animated: beast.animated === 1,
+ rank,
+ name,
+ prefixName,
+ suffixName,
+ fullName,
+ tier,
+ type,
+ power,
+ };
+}
+
+export function resolveBeastRenderDataList(
+ beasts: BeastOnchain[]
+): BeastRenderData[] {
+ return beasts
+ .map(resolveBeastRenderData)
+ .sort((a, b) => a.tokenId - b.tokenId);
+}
+
+export function toBeastSvgInput(beast: BeastRenderData): BeastSvgInput {
+ return {
+ beastId: beast.beastId,
+ prefix: beast.prefixName,
+ suffix: beast.suffixName,
+ tier: beast.tier,
+ level: beast.level,
+ health: beast.health,
+ power: beast.power,
+ shiny: beast.shiny,
+ rank: beast.rank,
+ animated: beast.animated,
+ };
+}
+
+export function normalizeStarknetAddress(address: string): string {
+ const trimmed = address.trim().toLowerCase();
+ if (!trimmed) {
+ throw new Error("Address is empty");
+ }
+ const hex = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
+ const value = BigInt(`0x${hex}`);
+ return `0x${value.toString(16).padStart(64, "0")}`;
+}
+
+function normalizeStarknetAddressLoose(address: string): string {
+ const trimmed = address.trim().toLowerCase();
+ if (!trimmed) {
+ return "";
+ }
+ const hex = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
+ if (!hex) {
+ return `0x${"0".repeat(64)}`;
+ }
+ if (hex.length > 64) {
+ return normalizeStarknetAddress(address);
+ }
+ return `0x${hex.padStart(64, "0")}`;
+}
+
+function formatFullName(
+ beastName: string,
+ prefix: string | null,
+ suffix: string | null
+): string {
+ if (prefix && suffix) {
+ return `"${prefix} ${suffix}" ${beastName}`;
+ }
+ if (prefix) {
+ return `"${prefix}" ${beastName}`;
+ }
+ if (suffix) {
+ return `"${suffix}" ${beastName}`;
+ }
+ return beastName;
+}
+
+function isGenesisToken(tokenId: number): boolean {
+ return tokenId >= 1 && tokenId <= 75;
+}
+
+function toHex(value: bigint | number): string {
+ return `0x${BigInt(value).toString(16)}`;
+}
+
+function toU256Calldata(value: bigint | number): [string, string] {
+ const big = BigInt(value);
+ const lowMask = (1n << 128n) - 1n;
+ const low = big & lowMask;
+ const high = big >> 128n;
+ return [toHex(low), toHex(high)];
+}
+
+function parseFeltNumber(value: string, field: string): number {
+ try {
+ return Number(BigInt(value));
+ } catch (error) {
+ throw new Error(`Invalid felt for ${field}: ${value}`);
+ }
+}
+
+function coerceNumber(value: unknown, field: string): number {
+ if (typeof value === "number") {
+ return value;
+ }
+ if (typeof value === "string") {
+ try {
+ return Number(BigInt(value));
+ } catch {
+ const parsed = parseInt(value, 10);
+ if (!Number.isNaN(parsed)) {
+ return parsed;
+ }
+ }
+ }
+ throw new Error(`Invalid number for ${field}`);
+}
+
+async function fetchJson(
+ fetchImpl: typeof fetch,
+ url: string,
+ init: RequestInit,
+ timeoutMs: number
+): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetchImpl(url, {
+ ...init,
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
+ }
+ return (await response.json()) as T;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+async function fetchSummitBeasts(
+ fetchImpl: typeof fetch,
+ baseUrl: string,
+ owner: string,
+ pageSize: number,
+ timeoutMs: number
+): Promise {
+ const beasts: BeastOnchain[] = [];
+ let offset = 0;
+ let hasMore = true;
+
+ while (hasMore) {
+ const url = new URL(`${baseUrl}/beasts/all`);
+ url.searchParams.set("owner", owner);
+ url.searchParams.set("limit", pageSize.toString());
+ url.searchParams.set("offset", offset.toString());
+
+ const response = await fetchJson(
+ fetchImpl,
+ url.toString(),
+ { method: "GET" },
+ timeoutMs
+ );
+
+ for (const row of response.data) {
+ beasts.push(parseSummitBeastRow(row));
+ }
+
+ hasMore = Boolean(response.pagination?.has_more);
+ offset += response.data.length;
+
+ if (response.data.length === 0) {
+ hasMore = false;
+ }
+ }
+
+ return beasts;
+}
+
+async function fetchSummitTokenIds(
+ fetchImpl: typeof fetch,
+ baseUrl: string,
+ owner: string,
+ pageSize: number,
+ timeoutMs: number
+): Promise {
+ const tokenIds: number[] = [];
+ let offset = 0;
+ let hasMore = true;
+
+ while (hasMore) {
+ const url = new URL(`${baseUrl}/beasts/all`);
+ url.searchParams.set("owner", owner);
+ url.searchParams.set("limit", pageSize.toString());
+ url.searchParams.set("offset", offset.toString());
+
+ const response = await fetchJson(
+ fetchImpl,
+ url.toString(),
+ { method: "GET" },
+ timeoutMs
+ );
+
+ for (const row of response.data) {
+ tokenIds.push(coerceNumber(row.token_id, "token_id"));
+ }
+
+ hasMore = Boolean(response.pagination?.has_more);
+ offset += response.data.length;
+
+ if (response.data.length === 0) {
+ hasMore = false;
+ }
+ }
+
+ return tokenIds;
+}
+
+function parseSummitBeastRow(row: SummitBeastRow): BeastOnchain {
+ return {
+ tokenId: coerceNumber(row.token_id, "token_id"),
+ beastId: coerceNumber(row.beast_id, "beast_id"),
+ prefix: coerceNumber(row.prefix, "prefix"),
+ suffix: coerceNumber(row.suffix, "suffix"),
+ level: coerceNumber(row.level, "level"),
+ health: coerceNumber(row.health, "health"),
+ shiny: coerceNumber(row.shiny, "shiny"),
+ animated: coerceNumber(row.animated, "animated"),
+ };
+}
+
+async function fetchVoyagerBeasts(
+ fetchImpl: typeof fetch,
+ apiUrl: string,
+ apiKey: string,
+ contractAddress: string,
+ owner: string,
+ pageSize: number,
+ timeoutMs: number
+): Promise {
+ const beasts: BeastOnchain[] = [];
+ let cursor: string | null = null;
+ let hasMore = true;
+
+ while (hasMore) {
+ const url = new URL(`${apiUrl}/nft-items`);
+ url.searchParams.set("contract_address", contractAddress);
+ url.searchParams.set("owner_address", owner);
+ url.searchParams.set("limit", pageSize.toString());
+ if (cursor) {
+ url.searchParams.set("cursor", cursor);
+ }
+
+ const response = await fetchJson(
+ fetchImpl,
+ url.toString(),
+ {
+ method: "GET",
+ headers: {
+ "x-api-key": apiKey,
+ "Content-Type": "application/json",
+ },
+ },
+ timeoutMs
+ );
+
+ for (const item of response.items) {
+ beasts.push(parseVoyagerBeastItem(item));
+ }
+
+ const nextCursor = extractCursorFromUrl(response.pagination?.next ?? null);
+ if (!nextCursor || response.items.length < pageSize) {
+ hasMore = false;
+ } else {
+ cursor = nextCursor;
+ }
+ }
+
+ return beasts;
+}
+
+async function fetchVoyagerTokenIds(
+ fetchImpl: typeof fetch,
+ apiUrl: string,
+ apiKey: string,
+ contractAddress: string,
+ owner: string,
+ pageSize: number,
+ timeoutMs: number
+): Promise {
+ const tokenIds: number[] = [];
+ let cursor: string | null = null;
+ let hasMore = true;
+
+ while (hasMore) {
+ const url = new URL(`${apiUrl}/nft-items`);
+ url.searchParams.set("contract_address", contractAddress);
+ url.searchParams.set("owner_address", owner);
+ url.searchParams.set("limit", pageSize.toString());
+ if (cursor) {
+ url.searchParams.set("cursor", cursor);
+ }
+
+ const response = await fetchJson(
+ fetchImpl,
+ url.toString(),
+ {
+ method: "GET",
+ headers: {
+ "x-api-key": apiKey,
+ "Content-Type": "application/json",
+ },
+ },
+ timeoutMs
+ );
+
+ for (const item of response.items) {
+ tokenIds.push(coerceNumber(item.tokenId, "tokenId"));
+ }
+
+ const nextCursor = extractCursorFromUrl(response.pagination?.next ?? null);
+ if (!nextCursor || response.items.length < pageSize) {
+ hasMore = false;
+ } else {
+ cursor = nextCursor;
+ }
+ }
+
+ return tokenIds;
+}
+
+function extractCursorFromUrl(url: string | null): string | null {
+ if (!url) return null;
+ try {
+ const urlObj = new URL(url, DEFAULT_VOYAGER_API_URL);
+ return urlObj.searchParams.get("cursor");
+ } catch {
+ return null;
+ }
+}
+
+function getAttributeValue(
+ attributes: VoyagerAttribute[] | null | undefined,
+ traitType: string
+): string | null {
+ if (!attributes) return null;
+ const attr = attributes.find((attr) => attr.trait_type === traitType);
+ return attr?.value ?? null;
+}
+
+function getAttributeNumber(
+ attributes: VoyagerAttribute[] | null | undefined,
+ traitType: string
+): number | null {
+ const value = getAttributeValue(attributes, traitType);
+ if (value == null) return null;
+ const parsed = parseInt(value, 10);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+function getAttributeBoolean(
+ attributes: VoyagerAttribute[] | null | undefined,
+ traitType: string
+): boolean {
+ const value = getAttributeValue(attributes, traitType);
+ if (value == null) return false;
+ const normalized = value.toLowerCase();
+ return normalized === "true" || normalized === "1";
+}
+
+function parseVoyagerBeastItem(item: VoyagerNftItem): BeastOnchain {
+ const attributes = item.attributes ?? null;
+ const tokenId = coerceNumber(item.tokenId, "tokenId");
+ const beastId =
+ getAttributeNumber(attributes, "Beast ID") ??
+ getAttributeNumber(attributes, "Beast Id") ??
+ getAttributeNumber(attributes, "BeastId") ??
+ getBeastIdByName(getAttributeValue(attributes, "Beast"));
+
+ if (beastId == null) {
+ throw new Error("Voyager item missing Beast ID");
+ }
+
+ const prefixName = getAttributeValue(attributes, "Prefix");
+ const suffixName = getAttributeValue(attributes, "Suffix");
+ const level = getAttributeNumber(attributes, "Level");
+ const health = getAttributeNumber(attributes, "Health");
+
+ if (level == null || health == null) {
+ throw new Error("Voyager item missing Level/Health");
+ }
+
+ const prefix = getPrefixIdByName(prefixName) ?? 0;
+ const suffix = getSuffixIdByName(suffixName) ?? 0;
+
+ const shiny =
+ getAttributeBoolean(attributes, "Shiny") ||
+ getAttributeBoolean(attributes, "Is Shiny");
+ const animated =
+ getAttributeBoolean(attributes, "Animated") ||
+ getAttributeBoolean(attributes, "Is Animated");
+ const rank =
+ getAttributeNumber(attributes, "Rank") ??
+ (isGenesisToken(tokenId) ? 0 : null);
+
+ return {
+ tokenId,
+ beastId,
+ prefix,
+ suffix,
+ level,
+ health,
+ shiny: shiny ? 1 : 0,
+ animated: animated ? 1 : 0,
+ rank,
+ };
+}
+
+async function rpcCall(
+ fetchImpl: typeof fetch,
+ rpcUrl: string,
+ method: string,
+ params: Record,
+ timeoutMs: number
+): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetchImpl(rpcUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
+ signal: controller.signal,
+ });
+
+ const data = (await response.json()) as {
+ result?: string[];
+ error?: { code: number; message: string };
+ };
+
+ if (data.error) {
+ throw new Error(`RPC error: ${data.error.message}`);
+ }
+
+ return data.result ?? [];
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+async function getTotalSupply(
+ fetchImpl: typeof fetch,
+ rpcUrl: string,
+ beastNftAddress: string,
+ timeoutMs: number
+): Promise {
+ const params = {
+ request: {
+ contract_address: beastNftAddress,
+ entry_point_selector: TOTAL_SUPPLY_SELECTOR,
+ calldata: [],
+ },
+ block_id: "latest",
+ };
+
+ const result = await rpcCall(
+ fetchImpl,
+ rpcUrl,
+ "starknet_call",
+ params,
+ timeoutMs
+ );
+
+ const low = result[0] ? BigInt(result[0]) : 0n;
+ const high = result[1] ? BigInt(result[1]) : 0n;
+ const total = low + (high << 128n);
+
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) {
+ throw new Error("total_supply exceeds MAX_SAFE_INTEGER");
+ }
+
+ return Number(total);
+}
+
+async function getTokenIdsByOwnerViaRpc(
+ fetchImpl: typeof fetch,
+ rpcUrl: string,
+ beastNftAddress: string,
+ dataProviderAddress: string,
+ owner: string,
+ batches: number,
+ timeoutMs: number
+): Promise {
+ const totalSupply = await getTotalSupply(
+ fetchImpl,
+ rpcUrl,
+ beastNftAddress,
+ timeoutMs
+ );
+
+ if (totalSupply === 0) {
+ return [];
+ }
+
+ const safeBatches = Math.max(1, batches);
+ const batchSize = Math.ceil(totalSupply / safeBatches);
+ const ranges: Array<[number, number]> = [];
+
+ for (let i = 0; i < safeBatches; i++) {
+ const start = i * batchSize + 1;
+ const end = Math.min((i + 1) * batchSize, totalSupply);
+ if (start <= totalSupply) {
+ ranges.push([start, end]);
+ }
+ }
+
+ const normalizedOwner = normalizeStarknetAddress(owner);
+
+ const results = await Promise.all(
+ ranges.map(([start, end]) =>
+ getBeastOwnersRange(
+ fetchImpl,
+ rpcUrl,
+ dataProviderAddress,
+ start,
+ end,
+ normalizedOwner,
+ timeoutMs
+ )
+ )
+ );
+
+ return results.flat().sort((a, b) => a - b);
+}
+
+async function getBeastOwnersRange(
+ fetchImpl: typeof fetch,
+ rpcUrl: string,
+ dataProviderAddress: string,
+ startToken: number,
+ endToken: number,
+ owner: string,
+ timeoutMs: number
+): Promise {
+ const params = {
+ request: {
+ contract_address: dataProviderAddress,
+ entry_point_selector: GET_BEAST_OWNERS_RANGE_SELECTOR,
+ calldata: [toHex(startToken), toHex(endToken)],
+ },
+ block_id: "latest",
+ };
+
+ const result = await rpcCall(
+ fetchImpl,
+ rpcUrl,
+ "starknet_call",
+ params,
+ timeoutMs
+ );
+
+ if (result.length === 0) {
+ return [];
+ }
+
+ const count = parseFeltNumber(result[0], "owners_length");
+ const matches: number[] = [];
+
+ for (let i = 0; i < count; i++) {
+ const ownerIndex = 1 + i;
+ if (ownerIndex >= result.length) break;
+ const tokenOwner = normalizeStarknetAddressLoose(result[ownerIndex]);
+ if (tokenOwner === owner) {
+ matches.push(startToken + i);
+ }
+ }
+
+ return matches;
+}
+
+async function getBeastsByTokenIdsViaRpc(
+ fetchImpl: typeof fetch,
+ rpcUrl: string,
+ beastNftAddress: string,
+ tokenIds: number[],
+ timeoutMs: number,
+ concurrency: number
+): Promise {
+ if (tokenIds.length === 0) return [];
+
+ const results = await mapWithConcurrency(tokenIds, concurrency, async (tokenId) => {
+ const params = {
+ request: {
+ contract_address: beastNftAddress,
+ entry_point_selector: GET_BEAST_SELECTOR,
+ calldata: toU256Calldata(tokenId),
+ },
+ block_id: "latest",
+ };
+
+ const result = await rpcCall(
+ fetchImpl,
+ rpcUrl,
+ "starknet_call",
+ params,
+ timeoutMs
+ );
+
+ if (result.length < 7) {
+ throw new Error(`RPC get_beast returned ${result.length} values`);
+ }
+
+ return {
+ tokenId,
+ beastId: parseFeltNumber(result[0], "beast_id"),
+ prefix: parseFeltNumber(result[1], "prefix"),
+ suffix: parseFeltNumber(result[2], "suffix"),
+ level: parseFeltNumber(result[3], "level"),
+ health: parseFeltNumber(result[4], "health"),
+ shiny: parseFeltNumber(result[5], "shiny"),
+ animated: parseFeltNumber(result[6], "animated"),
+ };
+ });
+
+ return results;
+}
+
+async function getBeastRanksViaRpc(
+ fetchImpl: typeof fetch,
+ rpcUrl: string,
+ beastNftAddress: string,
+ tokenIds: number[],
+ timeoutMs: number,
+ concurrency: number
+): Promise