From 42bd865317c3d1eed101b165d8326ffb96208590 Mon Sep 17 00:00:00 2001 From: gmemez Date: Wed, 29 Jan 2025 23:07:07 +0700 Subject: [PATCH 1/7] add inscriptions endpoints --- src/endpoints.ts | 6 ++ src/index.ts | 73 +++++++++++++- src/schemas/index.ts | 1 + src/schemas/inscription.ts | 47 +++++++++ src/test/data/test-data.ts | 45 +++++++++ src/test/integration/api.test.ts | 159 ++++++++++++++++++++++++++++++- src/test/unit/schemas.test.ts | 65 +++++++++++++ src/types/index.ts | 4 + 8 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/schemas/inscription.ts diff --git a/src/endpoints.ts b/src/endpoints.ts index a95df7c..82c167d 100644 --- a/src/endpoints.ts +++ b/src/endpoints.ts @@ -7,4 +7,10 @@ export const endpoints = { blockheight: '/blockheight', blocks: '/blocks', blocktime: '/blocktime', + inscription: (id: string) => `/inscription/${id}`, + inscriptionChild: (id: string, child: number) => + `/inscription/${id}/${child}`, + inscriptions: '/inscriptions', + inscriptionsByPage: (page: number) => `/inscriptions/${page}`, + inscriptionsByBlock: (height: number) => `/inscriptions/block/${height}`, } as const; diff --git a/src/index.ts b/src/index.ts index 1467bd3..d937dc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,17 @@ import { BlockHashSchema, AddressInfoSchema, BlocksResponseSchema, + InscriptionSchema, + InscriptionsResponseSchema, } from 'schemas'; -import type { Block, BlockHash, AddressInfo, BlocksResponse } from 'types'; +import type { + Block, + BlockHash, + AddressInfo, + BlocksResponse, + Inscription, + InscriptionsResponse, +} from 'types'; type ApiResponse = | { success: true; data: T } @@ -55,6 +64,34 @@ export class OrdClient { return result.data; } + private async fetchPost( + endpoint: string, + payload: P, + schema: T, + ): Promise> { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + + const data = await response.json(); + const result = schema.safeParse(data); + + if (!result.success) { + throw new Error(`Validation error: ${result.error.message}`); + } + + return result.data; + } + async getAddressInfo(address: string): Promise { return this.fetch(endpoints.address(address), AddressInfoSchema); } @@ -86,4 +123,38 @@ export class OrdClient { async getLatestBlockTime(): Promise { return this.fetch(endpoints.blocktime, z.number().int()); } + + async getInscription(id: string): Promise { + return this.fetch(endpoints.inscription(id), InscriptionSchema); + } + + async getInscriptionChild(id: string, child: number): Promise { + return this.fetch(endpoints.inscriptionChild(id, child), InscriptionSchema); + } + + async getLatestInscriptions(): Promise { + return this.fetch(endpoints.inscriptions, InscriptionsResponseSchema); + } + + async getInscriptionsByIds(ids: string[]): Promise { + return this.fetchPost( + endpoints.inscriptions, + ids, + z.array(InscriptionSchema), + ); + } + + async getInscriptionsByPage(page: number): Promise { + return this.fetch( + endpoints.inscriptionsByPage(page), + InscriptionsResponseSchema, + ); + } + + async getInscriptionsByBlock(height: number): Promise { + return this.fetch( + endpoints.inscriptionsByBlock(height), + InscriptionsResponseSchema, + ); + } } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index f3e79ba..b7d212b 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,3 +1,4 @@ export * from 'schemas/transaction'; export * from 'schemas/block'; export * from 'schemas/address'; +export * from 'schemas/inscription'; diff --git a/src/schemas/inscription.ts b/src/schemas/inscription.ts new file mode 100644 index 0000000..1a82300 --- /dev/null +++ b/src/schemas/inscription.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +export const CharmSchema = z.enum([ + 'burned', + 'coin', + 'cursed', + 'epic', + 'legendary', + 'lost', + 'mythic', + 'nineball', + 'palindrome', + 'rare', + 'reinscription', + 'unbound', + 'uncommon', + 'vindicated', +]); + +export const InscriptionSchema = z.object({ + address: z.string().nullable(), + charms: z.array(CharmSchema), + children_count: z.number().int().nonnegative().nullable().optional(), + children: z.array(z.string()), + content_length: z.number().int().nonnegative(), + content_type: z.string(), + effective_content_type: z.string(), + fee: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + id: z.string(), + next: z.string().nullable(), + number: z.number().int().nonnegative(), + parents: z.array(z.string()), + previous: z.string().nullable(), + rune: z.string().nullable(), + sat: z.number().int().nonnegative().nullable(), + satpoint: z.string(), + timestamp: z.number().int(), + value: z.number().int().nonnegative(), + metaprotocol: z.string().nullable().optional(), +}); + +export const InscriptionsResponseSchema = z.object({ + ids: z.array(z.string()), + more: z.boolean(), + page_index: z.number().int().nonnegative(), +}); diff --git a/src/test/data/test-data.ts b/src/test/data/test-data.ts index 89d6280..5c8c270 100644 --- a/src/test/data/test-data.ts +++ b/src/test/data/test-data.ts @@ -70,6 +70,9 @@ export const SAMPLE_BLOCKS_RESPONSE = { }, }; +export const SAMPLE_TX_ID = + '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799'; + export const SAMPLE_TRANSACTION = { version: 1, lock_time: 0, @@ -100,3 +103,45 @@ export const SAMPLE_OUTPUT = { value: 5000000000, script_pubkey: '76a914...', }; + +export const SAMPLE_INSCRIPTION = { + address: 'bc1ppth27qnr74qhusy9pmcyeaelgvsfky6qzquv9nf56gqmte59vfhqwkqguh', + charms: ['vindicated'], + children: [ + '681b5373c03e3f819231afd9227f54101395299c9e58356bda278e2f32bef2cdi0', + 'b1ef66c2d1a047cbaa6260b74daac43813924378fe08ef8545da4cb79e8fcf00i0', + ], + content_length: 793, + content_type: 'image/png', + effective_content_type: 'image/png', + fee: 322, + height: 767430, + id: '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0', + next: '26482871f33f1051f450f2da9af275794c0b5f1c61ebf35e4467fb42c2813403i0', + number: 0, + parents: [], + previous: null, + rune: null, + sat: null, + satpoint: + '47c7260764af2ee17aa584d9c035f2e5429aefd96b8016cfe0e3f0bcf04869a3:0:0', + timestamp: 1671049920, + value: 606, +}; + +export const SAMPLE_INSCRIPTIONS_RESPONSE = { + ids: [ + '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0', + '26482871f33f1051f450f2da9af275794c0b5f1c61ebf35e4467fb42c2813403i0', + ], + more: true, + page_index: 0, +}; + +export const SAMPLE_INSCRIPTION_ID = + '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0'; + +export const SAMPLE_CHILD_ID = + 'ab924ff229beca227bf40221faf492a20b5e2ee4f084524c84a5f98b80fe527fi0'; + +export const SAMPLE_BLOCK_HEIGHT = 767430; diff --git a/src/test/integration/api.test.ts b/src/test/integration/api.test.ts index 1b70e8f..f67cfd4 100644 --- a/src/test/integration/api.test.ts +++ b/src/test/integration/api.test.ts @@ -5,6 +5,9 @@ import { GENESIS_BLOCK, SAMPLE_ADDRESS, SAMPLE_ADDRESS_INFO, + SAMPLE_INSCRIPTION_ID, + SAMPLE_CHILD_ID, + SAMPLE_BLOCK_HEIGHT, } from '../data/test-data'; describe('API Integration Tests', () => { @@ -57,7 +60,9 @@ describe('API Integration Tests', () => { test( 'rejects invalid address format', async () => { - await expect(client.getAddressInfo('invalid-address')).rejects.toThrow(); + await expect( + client.getAddressInfo('invalid-address'), + ).rejects.toThrow(); }, TIMEOUT, ); @@ -201,4 +206,156 @@ describe('API Integration Tests', () => { TIMEOUT, ); }); + + describe('getInscription', () => { + test( + 'fetches inscription successfully', + async () => { + const inscription = await client.getInscription(SAMPLE_INSCRIPTION_ID); + expect(inscription.id).toBe(SAMPLE_INSCRIPTION_ID); + expect(inscription.address).toBeDefined(); + expect(Array.isArray(inscription.charms)).toBe(true); + expect(Array.isArray(inscription.children)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getInscription(SAMPLE_INSCRIPTION_ID), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionChild', () => { + test( + 'fetches child inscription successfully', + async () => { + const child = await client.getInscriptionChild( + SAMPLE_INSCRIPTION_ID, + 0, + ); + expect(child.id).toBeDefined(); + expect(Array.isArray(child.children)).toBe(true); + expect(child.parents).toContain(SAMPLE_INSCRIPTION_ID); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getInscriptionChild(SAMPLE_INSCRIPTION_ID, 0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getLatestInscriptions', () => { + test( + 'fetches latest inscriptions successfully', + async () => { + const response = await client.getLatestInscriptions(); + expect(Array.isArray(response.ids)).toBe(true); + expect(response.ids.length).toBeGreaterThan(0); + expect(typeof response.more).toBe('boolean'); + expect(typeof response.page_index).toBe('number'); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getLatestInscriptions()).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionsByPage', () => { + test( + 'fetches inscriptions by page successfully', + async () => { + const response = await client.getInscriptionsByPage(1); + expect(Array.isArray(response.ids)).toBe(true); + expect(response.page_index).toBe(1); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getInscriptionsByPage(1)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionsByBlock', () => { + test( + 'fetches inscriptions by block successfully', + async () => { + const response = + await client.getInscriptionsByBlock(SAMPLE_BLOCK_HEIGHT); + expect(Array.isArray(response.ids)).toBe(true); + expect(response.ids).toContain(SAMPLE_INSCRIPTION_ID); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getInscriptionsByBlock(SAMPLE_BLOCK_HEIGHT), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionsByIds', () => { + test( + 'fetches multiple inscriptions successfully', + async () => { + const inscriptions = await client.getInscriptionsByIds([ + SAMPLE_INSCRIPTION_ID, + SAMPLE_CHILD_ID, + ]); + expect(Array.isArray(inscriptions)).toBe(true); + expect(inscriptions.length).toBe(2); + expect(inscriptions[0].id).toBe(SAMPLE_INSCRIPTION_ID); + expect(inscriptions[1].id).toBe(SAMPLE_CHILD_ID); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getInscriptionsByIds([SAMPLE_INSCRIPTION_ID]), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles empty array', + async () => { + const inscriptions = await client.getInscriptionsByIds([]); + expect(Array.isArray(inscriptions)).toBe(true); + expect(inscriptions.length).toBe(0); + }, + TIMEOUT, + ); + }); }); diff --git a/src/test/unit/schemas.test.ts b/src/test/unit/schemas.test.ts index eb95b79..74adfed 100644 --- a/src/test/unit/schemas.test.ts +++ b/src/test/unit/schemas.test.ts @@ -10,6 +10,10 @@ import { OutputSchema, TransactionSchema, } from '../../schemas/transaction'; +import { + InscriptionSchema, + InscriptionsResponseSchema, +} from '../../schemas/inscription'; import { GENESIS_BLOCK, SAMPLE_ADDRESS_INFO, @@ -18,6 +22,8 @@ import { SAMPLE_INPUT, SAMPLE_OUTPUT, SAMPLE_RUNE_BALANCE, + SAMPLE_INSCRIPTION, + SAMPLE_INSCRIPTIONS_RESPONSE, } from '../data/test-data'; describe('Schema Validation', () => { @@ -218,4 +224,63 @@ describe('Schema Validation', () => { }); }); }); + + describe('Inscription Schemas', () => { + describe('InscriptionSchema', () => { + test('validates valid inscription', () => { + const result = InscriptionSchema.safeParse(SAMPLE_INSCRIPTION); + expect(result.success).toBe(true); + }); + + test('rejects invalid charm value', () => { + const inscriptionWithInvalidCharm = { + ...SAMPLE_INSCRIPTION, + charms: ['invalid_charm'], + }; + const result = InscriptionSchema.safeParse(inscriptionWithInvalidCharm); + expect(result.success).toBe(false); + }); + + test('validates empty arrays', () => { + const inscriptionWithEmptyArrays = { + ...SAMPLE_INSCRIPTION, + charms: [], + children: [], + parents: [], + }; + const result = InscriptionSchema.safeParse(inscriptionWithEmptyArrays); + expect(result.success).toBe(true); + }); + + test('rejects negative values', () => { + const inscriptionWithNegatives = { + ...SAMPLE_INSCRIPTION, + content_length: -1, + fee: -1, + value: -1, + }; + const result = InscriptionSchema.safeParse(inscriptionWithNegatives); + expect(result.success).toBe(false); + }); + }); + + describe('InscriptionsResponseSchema', () => { + test('validates valid response', () => { + const result = InscriptionsResponseSchema.safeParse( + SAMPLE_INSCRIPTIONS_RESPONSE, + ); + expect(result.success).toBe(true); + }); + + test('rejects invalid types', () => { + const invalidResponse = { + SAMPLE_INSCRIPTIONS_RESPONSE, + ids: [123], // should be strings + page_index: -1, // should be non-negative + }; + const result = InscriptionsResponseSchema.safeParse(invalidResponse); + expect(result.success).toBe(false); + }); + }); + }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 47c309f..2287ec8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,8 @@ import { BlocksResponseSchema, TransactionSchema, AddressInfoSchema, + InscriptionSchema, + InscriptionsResponseSchema, } from 'schemas'; export type Block = z.infer; @@ -12,3 +14,5 @@ export type BlockHash = z.infer; export type BlocksResponse = z.infer; export type Transaction = z.infer; export type AddressInfo = z.infer; +export type Inscription = z.infer; +export type InscriptionsResponse = z.infer; From e3ad60e450b81c508d1b68b682bb2ff285e5bb4a Mon Sep 17 00:00:00 2001 From: gmemez Date: Thu, 30 Jan 2025 22:22:30 +0700 Subject: [PATCH 2/7] add remaining endpoints --- README.md | 36 +++- package.json | 12 +- src/api.ts | 41 +++++ src/client.ts | 218 ++++++++++++++++++++++ src/endpoints.ts | 16 -- src/index.ts | 162 +--------------- src/schemas/address.ts | 4 +- src/schemas/block.ts | 4 +- src/schemas/index.ts | 12 +- src/schemas/output.ts | 32 ++++ src/schemas/rune.ts | 37 ++++ src/schemas/sat.ts | 29 +++ src/schemas/status.ts | 25 +++ src/schemas/transaction.ts | 18 +- src/test/data/test-data.ts | 114 +++++++++++- src/test/integration/api.test.ts | 305 ++++++++++++++++++++++++++++++- src/test/unit/schemas.test.ts | 276 +++++++++++++++++++++++++--- src/types/index.ts | 45 ++++- tsconfig.json | 15 +- 19 files changed, 1147 insertions(+), 254 deletions(-) create mode 100644 src/api.ts create mode 100644 src/client.ts delete mode 100644 src/endpoints.ts create mode 100644 src/schemas/output.ts create mode 100644 src/schemas/rune.ts create mode 100644 src/schemas/sat.ts create mode 100644 src/schemas/status.ts diff --git a/README.md b/README.md index 34a3377..b1a0c99 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,39 @@ Using bun: $ bun add ordapi ``` +## Import + +Import the client and types depending on your needs: + +```typescript +// Using default import +import OrdClient from 'ordapi'; + +const client = new OrdClient('https://ord-server.com'); +const block = await client.getBlock(0); +``` + +```typescript +// Using both client and types +import OrdClient, { Inscription } from 'ordapi'; + +async function getInscription(id: string): Promise { + const client = new OrdClient('https://ord-server.com'); + return await client.getInscription(id); +} +``` + ## Usage ```typescript -import { OrdClient, Block } from 'ordapi'; +import OrdClient, { Block } from 'ordapi'; function App() { const [blockInfo, setBlockInfo] = useState(null); useEffect(() => { // Create client instance - const client = new OrdClient('https://your-ord-server.xyz'); + const client = new OrdClient('https://ord-server.xyz'); // Fetch genesis block info async function fetchBlock() { @@ -57,9 +79,13 @@ function App() { return (

Genesis Block

-

Height: {blockInfo.height}

-

Hash: {blockInfo.hash}

-

Number of transactions: {blockInfo.transactions.length}

+ {blockInfo && ( + <> +

Height: {blockInfo.height}

+

Hash: {blockInfo.hash}

+

Number of transactions: {blockInfo.transactions.length}

+ + )}
); } diff --git a/package.json b/package.json index be4badf..fb741dc 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,22 @@ { "name": "ordapi", "version": "0.0.2", - "module": "dist/index.js", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist"], "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target node", + "build": "tsc --emitDeclarationOnly && bun build ./src/index.ts --outdir ./dist --target node", "test": "bun test ./src/test/**/*.test.ts", "format": "prettier --write 'src/**/*.ts'", "lint": "eslint . && tsc -b", "prepublishOnly": "bun run build" }, - "imports": { - "#src/*": "./src/*", - "#schemas/*": "./src/schemas/*", - "#types/*": "./src/types/*" + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } }, "dependencies": { "typescript-eslint": "^8.22.0", diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..d208c6e --- /dev/null +++ b/src/api.ts @@ -0,0 +1,41 @@ +import { OutputType } from './types'; + +const api = { + address: (address: string) => `/address/${address}`, + block: (heightOrHash: number | string) => `/block/${heightOrHash}`, + blockcount: '/blockcount', + blockhash: { + latest: '/blockhash', + byHeight: (height: number) => `/blockhash/${height}`, + }, + blockheight: '/blockheight', + blocks: '/blocks', + blocktime: '/blocktime', + inscription: (id: string) => `/inscription/${id}`, + inscriptionChild: (id: string, child: number) => + `/inscription/${id}/${child}`, + inscriptions: { + base: '/inscriptions', + latest: '/inscriptions', + byPage: (page: number) => `/inscriptions/${page}`, + byBlock: (height: number) => `/inscriptions/block/${height}`, + }, + output: (outpoint: string) => `/output/${outpoint}`, + outputs: { + base: '/outputs', + byAddress: (address: string, type?: OutputType) => { + const base = `/outputs/${address}`; + return type ? `${base}?type=${type}` : base; + }, + }, + rune: (name: string) => `/rune/${name}`, + runes: { + latest: '/runes', + byPage: (page: number) => `/runes/${page}`, + }, + sat: (number: number) => `/sat/${number}`, + tx: (txId: string) => `/tx/${txId}`, + status: '/status', +} as const; + +export default api; diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..e4f868f --- /dev/null +++ b/src/client.ts @@ -0,0 +1,218 @@ +import { z } from 'zod'; +import api from './api'; +import { + BlockSchema, + BlockHashSchema, + AddressInfoSchema, + BlocksResponseSchema, + InscriptionSchema, + InscriptionsResponseSchema, + OutputSchema, + RuneResponseSchema, + RunesResponseSchema, + SatSchema, + StatusSchema, + TxDetailsSchema, +} from './schemas'; +import type { + BlockInfo, + BlockHash, + AddressInfo, + BlocksResponse, + InscriptionInfo, + InscriptionsResponse, + OutputInfo, + RuneResponse, + RunesResponse, + SatInfo, + TransactionInfo, + Status, + OutputType, +} from './types'; + +type ApiResponse = + | { success: true; data: T } + | { success: false; error: string }; + +export class OrdClient { + private headers: HeadersInit; + + constructor( + private baseUrl: string, + headers: HeadersInit = {}, + ) { + this.headers = { + Accept: 'application/json', + ...headers, + }; + } + + private async fetch( + endpoint: string, + schema: T, + ): Promise> { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + headers: this.headers, + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + + const text = await response.text(); + const result = schema.safeParse(text); + + if (!result.success) { + try { + const json = JSON.parse(text); + const jsonResult = schema.safeParse(json); + if (jsonResult.success) { + return jsonResult.data; + } + } catch {} + + throw new Error(`Validation error: ${result.error.message}`); + } + + return result.data; + } + + private async fetchPost( + endpoint: string, + payload: P, + schema: T, + ): Promise> { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + + const data = await response.json(); + const result = schema.safeParse(data); + + if (!result.success) { + throw new Error(`Validation error: ${result.error.message}`); + } + + return result.data; + } + + async getAddressInfo(address: string): Promise { + return this.fetch(api.address(address), AddressInfoSchema); + } + + async getBlock(heightOrHash: number | BlockHash): Promise { + return this.fetch(api.block(heightOrHash), BlockSchema); + } + + async getBlockCount(): Promise { + return this.fetch(api.blockcount, z.number().int().nonnegative()); + } + + async getBlockHashByHeight(height: number): Promise { + return this.fetch(api.blockhash.byHeight(height), BlockHashSchema); + } + + async getLatestBlockHash(): Promise { + return this.fetch(api.blockhash.latest, BlockHashSchema); + } + + async getLatestBlockHeight(): Promise { + return this.fetch(api.blockheight, z.number().int().nonnegative()); + } + + async getLatestBlocks(): Promise { + return this.fetch(api.blocks, BlocksResponseSchema); + } + + async getLatestBlockTime(): Promise { + return this.fetch(api.blocktime, z.number().int()); + } + + async getInscription(id: string): Promise { + return this.fetch(api.inscription(id), InscriptionSchema); + } + + async getInscriptionChild( + id: string, + child: number, + ): Promise { + return this.fetch(api.inscriptionChild(id, child), InscriptionSchema); + } + + async getLatestInscriptions(): Promise { + return this.fetch(api.inscriptions.latest, InscriptionsResponseSchema); + } + + async getInscriptionsByIds(ids: string[]): Promise { + return this.fetchPost( + api.inscriptions.base, + ids, + z.array(InscriptionSchema), + ); + } + + async getInscriptionsByPage(page: number): Promise { + return this.fetch( + api.inscriptions.byPage(page), + InscriptionsResponseSchema, + ); + } + + async getInscriptionsByBlock(height: number): Promise { + return this.fetch( + api.inscriptions.byBlock(height), + InscriptionsResponseSchema, + ); + } + + async getOutput(outpoint: string): Promise { + return this.fetch(api.output(outpoint), OutputSchema); + } + + async getOutputs(outpoints: string[]): Promise { + return this.fetchPost(api.outputs.base, outpoints, z.array(OutputSchema)); + } + + async getOutputsByAddress( + address: string, + type?: OutputType, + ): Promise { + return this.fetch( + api.outputs.byAddress(address, type), + z.array(OutputSchema), + ); + } + + async getRune(name: string): Promise { + return this.fetch(api.rune(name), RuneResponseSchema); + } + + async getLatestRunes(): Promise { + return this.fetch(api.runes.latest, RunesResponseSchema); + } + + async getRunesByPage(page: number): Promise { + return this.fetch(api.runes.byPage(page), RunesResponseSchema); + } + + async getSat(number: number): Promise { + return this.fetch(api.sat(number), SatSchema); + } + + async getTx(txId: string): Promise { + return this.fetch(api.tx(txId), TxDetailsSchema); + } + + async getStatus(): Promise { + return this.fetch(api.status, StatusSchema); + } +} diff --git a/src/endpoints.ts b/src/endpoints.ts deleted file mode 100644 index 82c167d..0000000 --- a/src/endpoints.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const endpoints = { - address: (address: string) => `/address/${address}`, - block: (heightOrHash: number | string) => `/block/${heightOrHash}`, - blockcount: '/blockcount', - blockhashLatest: '/blockhash', - blockhashByHeight: (height: number) => `/blockhash/${height}`, - blockheight: '/blockheight', - blocks: '/blocks', - blocktime: '/blocktime', - inscription: (id: string) => `/inscription/${id}`, - inscriptionChild: (id: string, child: number) => - `/inscription/${id}/${child}`, - inscriptions: '/inscriptions', - inscriptionsByPage: (page: number) => `/inscriptions/${page}`, - inscriptionsByBlock: (height: number) => `/inscriptions/block/${height}`, -} as const; diff --git a/src/index.ts b/src/index.ts index d937dc7..f561812 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,160 +1,4 @@ -import { z } from 'zod'; -import { endpoints } from 'src/endpoints'; -import { - BlockSchema, - BlockHashSchema, - AddressInfoSchema, - BlocksResponseSchema, - InscriptionSchema, - InscriptionsResponseSchema, -} from 'schemas'; -import type { - Block, - BlockHash, - AddressInfo, - BlocksResponse, - Inscription, - InscriptionsResponse, -} from 'types'; +import { OrdClient } from './client'; +export type * from './types'; -type ApiResponse = - | { success: true; data: T } - | { success: false; error: string }; - -export class OrdClient { - private headers: HeadersInit; - - constructor( - private baseUrl: string, - headers: HeadersInit = {}, - ) { - this.headers = { - Accept: 'application/json', - ...headers, - }; - } - - private async fetch( - endpoint: string, - schema: T, - ): Promise> { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - headers: this.headers, - }); - - if (!response.ok) { - throw new Error(`API request failed: ${response.statusText}`); - } - - const text = await response.text(); - const result = schema.safeParse(text); - - if (!result.success) { - try { - const json = JSON.parse(text); - const jsonResult = schema.safeParse(json); - if (jsonResult.success) { - return jsonResult.data; - } - } catch {} - - throw new Error(`Validation error: ${result.error.message}`); - } - - return result.data; - } - - private async fetchPost( - endpoint: string, - payload: P, - schema: T, - ): Promise> { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: 'POST', - headers: { - ...this.headers, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(`API request failed: ${response.statusText}`); - } - - const data = await response.json(); - const result = schema.safeParse(data); - - if (!result.success) { - throw new Error(`Validation error: ${result.error.message}`); - } - - return result.data; - } - - async getAddressInfo(address: string): Promise { - return this.fetch(endpoints.address(address), AddressInfoSchema); - } - - async getBlock(heightOrHash: number | BlockHash): Promise { - return this.fetch(endpoints.block(heightOrHash), BlockSchema); - } - - async getBlockCount(): Promise { - return this.fetch(endpoints.blockcount, z.number().int().nonnegative()); - } - - async getBlockHashByHeight(height: number): Promise { - return this.fetch(endpoints.blockhashByHeight(height), BlockHashSchema); - } - - async getLatestBlockHash(): Promise { - return this.fetch(endpoints.blockhashLatest, BlockHashSchema); - } - - async getLatestBlockHeight(): Promise { - return this.fetch(endpoints.blockheight, z.number().int().nonnegative()); - } - - async getLatestBlocks(): Promise { - return this.fetch(endpoints.blocks, BlocksResponseSchema); - } - - async getLatestBlockTime(): Promise { - return this.fetch(endpoints.blocktime, z.number().int()); - } - - async getInscription(id: string): Promise { - return this.fetch(endpoints.inscription(id), InscriptionSchema); - } - - async getInscriptionChild(id: string, child: number): Promise { - return this.fetch(endpoints.inscriptionChild(id, child), InscriptionSchema); - } - - async getLatestInscriptions(): Promise { - return this.fetch(endpoints.inscriptions, InscriptionsResponseSchema); - } - - async getInscriptionsByIds(ids: string[]): Promise { - return this.fetchPost( - endpoints.inscriptions, - ids, - z.array(InscriptionSchema), - ); - } - - async getInscriptionsByPage(page: number): Promise { - return this.fetch( - endpoints.inscriptionsByPage(page), - InscriptionsResponseSchema, - ); - } - - async getInscriptionsByBlock(height: number): Promise { - return this.fetch( - endpoints.inscriptionsByBlock(height), - InscriptionsResponseSchema, - ); - } -} +export default OrdClient; diff --git a/src/schemas/address.ts b/src/schemas/address.ts index 93641c3..3389e8f 100644 --- a/src/schemas/address.ts +++ b/src/schemas/address.ts @@ -1,10 +1,8 @@ import { z } from 'zod'; -export const RuneBalanceSchema = z.tuple([z.string(), z.string(), z.string()]); - export const AddressInfoSchema = z.object({ outputs: z.array(z.string()), inscriptions: z.array(z.string()), sat_balance: z.number().int().nonnegative(), - runes_balances: z.array(RuneBalanceSchema), + runes_balances: z.array(z.array(z.string())), }); diff --git a/src/schemas/block.ts b/src/schemas/block.ts index 7d17158..15d549e 100644 --- a/src/schemas/block.ts +++ b/src/schemas/block.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { TransactionSchema } from 'schemas'; +import { TxSchema } from './transaction'; const isHexString = (str: string) => /^[0-9a-fA-F]+$/.test(str); @@ -18,7 +18,7 @@ export const BlockSchema = z.object({ inscriptions: z.array(z.string()), runes: z.array(z.string()), target: z.string(), - transactions: z.array(TransactionSchema), + transactions: z.array(TxSchema), }); export const BlocksResponseSchema = z.object({ diff --git a/src/schemas/index.ts b/src/schemas/index.ts index b7d212b..8b9bd1f 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,4 +1,8 @@ -export * from 'schemas/transaction'; -export * from 'schemas/block'; -export * from 'schemas/address'; -export * from 'schemas/inscription'; +export * from './transaction'; +export * from './block'; +export * from './address'; +export * from './inscription'; +export * from './output'; +export * from './rune'; +export * from './sat'; +export * from './status'; diff --git a/src/schemas/output.ts b/src/schemas/output.ts new file mode 100644 index 0000000..52ed100 --- /dev/null +++ b/src/schemas/output.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const OutputTypeSchema = z.enum([ + 'any', + 'cardinal', + 'inscribed', + 'runic', +]); + +const SatRangeSchema = z.tuple([ + z.number().int().nonnegative(), + z.number().int().nonnegative(), +]); + +const RuneSchema = z.object({ + amount: z.number().int().nonnegative(), + divisibility: z.number().int().nonnegative(), + symbol: z.string(), +}); + +export const OutputSchema = z.object({ + address: z.string(), + indexed: z.boolean(), + inscriptions: z.array(z.string()), + outpoint: z.string(), + runes: z.record(z.string(), RuneSchema).optional(), + sat_ranges: z.array(SatRangeSchema).nullable(), + script_pubkey: z.string(), + spent: z.boolean(), + transaction: z.string(), + value: z.number().int().nonnegative(), +}); diff --git a/src/schemas/rune.ts b/src/schemas/rune.ts new file mode 100644 index 0000000..3c421a9 --- /dev/null +++ b/src/schemas/rune.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const RuneTermsSchema = z.object({ + amount: z.number().int().nonnegative(), + cap: z.number().int().nonnegative(), + height: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), + offset: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), +}); + +export const RuneSchema = z.object({ + block: z.number().int().nonnegative(), + burned: z.number().int().nonnegative(), + divisibility: z.number().int().nonnegative(), + etching: z.string(), + mints: z.number().int().nonnegative(), + number: z.number().int().nonnegative(), + premine: z.number().int().nonnegative(), + spaced_rune: z.string(), + symbol: z.string().nullable(), + terms: RuneTermsSchema, + timestamp: z.number().int(), + turbo: z.boolean(), +}); + +export const RuneResponseSchema = z.object({ + entry: RuneSchema, + id: z.string(), + mintable: z.boolean(), + parent: z.string().nullable(), +}); + +export const RunesResponseSchema = z.object({ + entries: z.array(z.tuple([z.string(), RuneSchema])), + more: z.boolean(), + prev: z.number().nullable(), + next: z.number().nullable(), +}); diff --git a/src/schemas/sat.ts b/src/schemas/sat.ts new file mode 100644 index 0000000..4543645 --- /dev/null +++ b/src/schemas/sat.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { CharmSchema } from './inscription'; + +export const RaritySchema = z.enum([ + 'common', + 'uncommon', + 'rare', + 'epic', + 'legendary', + 'mythic', +]); + +export const SatSchema = z.object({ + block: z.number().int().nonnegative(), + charms: z.array(CharmSchema), + cycle: z.number().int().nonnegative(), + decimal: z.string(), + degree: z.string(), + epoch: z.number().int().nonnegative(), + inscriptions: z.array(z.string()), + name: z.string(), + number: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + percentile: z.string(), + period: z.number().int().nonnegative(), + rarity: RaritySchema, + satpoint: z.string().nullable(), + timestamp: z.number().int(), +}); diff --git a/src/schemas/status.ts b/src/schemas/status.ts new file mode 100644 index 0000000..06f19d8 --- /dev/null +++ b/src/schemas/status.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +const TimeSchema = z.object({ + secs: z.number().int().nonnegative(), + nanos: z.number().int().nonnegative(), +}); + +export const StatusSchema = z.object({ + address_index: z.boolean(), + blessed_inscriptions: z.number().int().nonnegative(), + chain: z.string(), + cursed_inscriptions: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + initial_sync_time: TimeSchema, + inscriptions: z.number().int().nonnegative(), + lost_sats: z.number().int().nonnegative(), + minimum_rune_for_next_block: z.string().nullable(), + rune_index: z.boolean(), + runes: z.number().int().nonnegative(), + sat_index: z.boolean(), + started: z.string(), + transaction_index: z.boolean(), + unrecoverably_reorged: z.boolean(), + uptime: TimeSchema, +}); diff --git a/src/schemas/transaction.ts b/src/schemas/transaction.ts index d7989b0..1857862 100644 --- a/src/schemas/transaction.ts +++ b/src/schemas/transaction.ts @@ -1,20 +1,28 @@ import { z } from 'zod'; -export const InputSchema = z.object({ +export const TxInputSchema = z.object({ previous_output: z.string(), script_sig: z.string(), sequence: z.number().int().nonnegative(), witness: z.array(z.string()), }); -export const OutputSchema = z.object({ +export const TxOutputSchema = z.object({ value: z.number().int().nonnegative(), script_pubkey: z.string(), }); -export const TransactionSchema = z.object({ +export const TxSchema = z.object({ version: z.number().int().nonnegative(), lock_time: z.number().int().nonnegative(), - input: z.array(InputSchema), - output: z.array(OutputSchema), + input: z.array(TxInputSchema), + output: z.array(TxOutputSchema), +}); + +export const TxDetailsSchema = z.object({ + chain: z.string(), + etching: z.string().nullable(), + inscription_count: z.number().int().nonnegative(), + transaction: TxSchema, + txid: z.string(), }); diff --git a/src/test/data/test-data.ts b/src/test/data/test-data.ts index 5c8c270..c88d39d 100644 --- a/src/test/data/test-data.ts +++ b/src/test/data/test-data.ts @@ -30,7 +30,7 @@ export const GENESIS_BLOCK = { ], }; -export const SAMPLE_ADDRESS = +export const SAMPLE_ORDINALS_ADDRESS = 'bc1pyy0ttst33sgv9vx0jnqueca7vsqqupwu2t38l43pfgpwjrqvdddsq73hzp'; export const SAMPLE_ADDRESS_INFO = { @@ -51,7 +51,7 @@ export const SAMPLE_ADDRESS_INFO = { ['GREED•FRAGMENTS', '417', '∞'], ['LIQUIDIUM•TOKEN', '117.42', '🫠'], ['EPIC•EPIC•EPIC•EPIC', '20000', '💥'], - ], + ] }; export const SAMPLE_RUNE_BALANCE = ['TEST•RUNE', '100', '🎯']; @@ -70,6 +70,14 @@ export const SAMPLE_BLOCKS_RESPONSE = { }, }; +export const SAMPLE_OUTPOINT_A = + 'e553c4f6742ec65893611778a2f90305ac6be25f84771f505366b729a179af8c:0'; + +export const SAMPLE_OUTPOINT_B = + '85abae61cf0f7f90efc67ab5059e6ee3e600c3015ea68e9b33e945d8555766ed:100'; + +export const SAMPLE_BTC_ADDRESS = '358mMRwcxuCSkKheuVWaXHJBGKrXo3f6JW'; + export const SAMPLE_TX_ID = '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799'; @@ -111,6 +119,7 @@ export const SAMPLE_INSCRIPTION = { '681b5373c03e3f819231afd9227f54101395299c9e58356bda278e2f32bef2cdi0', 'b1ef66c2d1a047cbaa6260b74daac43813924378fe08ef8545da4cb79e8fcf00i0', ], + children_count: 2, content_length: 793, content_type: 'image/png', effective_content_type: 'image/png', @@ -119,14 +128,15 @@ export const SAMPLE_INSCRIPTION = { id: '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0', next: '26482871f33f1051f450f2da9af275794c0b5f1c61ebf35e4467fb42c2813403i0', number: 0, - parents: [], - previous: null, - rune: null, - sat: null, + parents: ['parent1', 'parent2'], + previous: 'prev-inscription-id', + rune: 'SOME•RUNE', + sat: 5000000000, satpoint: '47c7260764af2ee17aa584d9c035f2e5429aefd96b8016cfe0e3f0bcf04869a3:0:0', timestamp: 1671049920, value: 606, + metaprotocol: 'protocol-name', }; export const SAMPLE_INSCRIPTIONS_RESPONSE = { @@ -145,3 +155,95 @@ export const SAMPLE_CHILD_ID = 'ab924ff229beca227bf40221faf492a20b5e2ee4f084524c84a5f98b80fe527fi0'; export const SAMPLE_BLOCK_HEIGHT = 767430; + +export const SAMPLE_UTXO_INFO = { + address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + indexed: true, + inscriptions: [ + '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0', + ], + outpoint: + '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799:0', + runes: { + 'TEST•RUNE': { + amount: 1000, + divisibility: 0, + symbol: '🎯', + }, + }, + sat_ranges: [[0, 100]], + script_pubkey: '0014751e76e8199196d454941c45d1b3a323f1433bd6', + spent: false, + transaction: + '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799', + value: 100000000, +}; + +export const SAMPLE_RUNE = { + block: 840000, + burned: 0, + divisibility: 0, + etching: '6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799i0', + mints: 1, + number: 100, + premine: 1000, + spaced_rune: 'TEST•RUNE', + symbol: '🎯', + terms: { + amount: 100, + cap: 1000, + height: [840000, 850000], + offset: [1000, 9000], + }, + timestamp: 1677654321, + turbo: false, +}; + +export const SAMPLE_RUNE_NAME = 'MEMENTO•MORI'; + +export const SAMPLE_SAT_NUMBER = 2099994106992659; + +export const SAMPLE_SAT = { + block: 1000, + charms: ['uncommon', 'cursed'], + cycle: 0, + decimal: '1000.0', + degree: '0°1′0″0‴', + epoch: 0, + inscriptions: [], + name: 'nvtdijuwxlp', + number: 2099994106992659, + offset: 0, + percentile: '99.99%', + period: 0, + rarity: 'uncommon', + satpoint: null, + timestamp: 1231006505, +}; + +export const SAMPLE_STATUS = { + address_index: true, + blessed_inscriptions: 83938677, + chain: 'mainnet', + cursed_inscriptions: 472043, + height: 881492, + initial_sync_time: { + secs: 78661, + nanos: 442762000, + }, + inscription_index: true, + inscriptions: 84410720, + json_api: true, + lost_sats: 2895502904, + minimum_rune_for_next_block: 'QJDUTCPVQI', + rune_index: true, + runes: 170685, + sat_index: true, + started: '2025-01-27T22:21:24.022870640Z', + transaction_index: false, + unrecoverably_reorged: false, + uptime: { + secs: 225097, + nanos: 72225343, + }, +}; diff --git a/src/test/integration/api.test.ts b/src/test/integration/api.test.ts index f67cfd4..694559a 100644 --- a/src/test/integration/api.test.ts +++ b/src/test/integration/api.test.ts @@ -1,13 +1,19 @@ import { expect, test, describe, beforeAll } from 'bun:test'; -import { OrdClient } from '../../index'; +import OrdClient from '../../index'; import { BASE_URL, TIMEOUT } from '../config/test-config'; import { GENESIS_BLOCK, - SAMPLE_ADDRESS, - SAMPLE_ADDRESS_INFO, + SAMPLE_ORDINALS_ADDRESS, SAMPLE_INSCRIPTION_ID, SAMPLE_CHILD_ID, SAMPLE_BLOCK_HEIGHT, + SAMPLE_SAT_NUMBER, + SAMPLE_TX_ID, + SAMPLE_RUNE_NAME, + SAMPLE_OUTPOINT_A, + SAMPLE_OUTPOINT_B, + SAMPLE_BTC_ADDRESS, + SAMPLE_ADDRESS_INFO, } from '../data/test-data'; describe('API Integration Tests', () => { @@ -51,7 +57,7 @@ describe('API Integration Tests', () => { test( 'fetches address info successfully', async () => { - const info = await client.getAddressInfo(SAMPLE_ADDRESS); + const info = await client.getAddressInfo(SAMPLE_ORDINALS_ADDRESS); expect(info).toEqual(SAMPLE_ADDRESS_INFO); }, TIMEOUT, @@ -71,7 +77,7 @@ describe('API Integration Tests', () => { 'handles server error', async () => { await expect( - invalidClient.getAddressInfo(SAMPLE_ADDRESS), + invalidClient.getAddressInfo(SAMPLE_ORDINALS_ADDRESS), ).rejects.toThrow(); }, TIMEOUT, @@ -358,4 +364,293 @@ describe('API Integration Tests', () => { TIMEOUT, ); }); + + describe('getOutput', () => { + test( + 'fetches output successfully', + async () => { + const output = await client.getOutput(SAMPLE_OUTPOINT_A); + expect(output.outpoint).toBe(SAMPLE_OUTPOINT_A); + expect(output.value).toBeGreaterThan(0); + expect(Array.isArray(output.sat_ranges)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles invalid outpoint format', + async () => { + await expect(client.getOutput('invalid-outpoint')).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getOutput(SAMPLE_OUTPOINT_A), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getOutputs', () => { + test( + 'fetches multiple outputs successfully', + async () => { + const outpoints = [SAMPLE_OUTPOINT_A, SAMPLE_OUTPOINT_B]; + const outputs = await client.getOutputs(outpoints); + expect(Array.isArray(outputs)).toBe(true); + expect(outputs[0].outpoint).toBe(SAMPLE_OUTPOINT_A); + expect(outputs[1].outpoint).toBe(SAMPLE_OUTPOINT_B); + }, + TIMEOUT, + ); + + test( + 'handles empty array', + async () => { + const outputs = await client.getOutputs([]); + expect(Array.isArray(outputs)).toBe(true); + expect(outputs.length).toBe(0); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getOutputs([SAMPLE_OUTPOINT_A, SAMPLE_OUTPOINT_B]), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getOutputsByAddress', () => { + test( + 'fetches outputs by address successfully', + async () => { + const outputs = await client.getOutputsByAddress(SAMPLE_BTC_ADDRESS); + expect(Array.isArray(outputs)).toBe(true); + if (outputs.length > 0) { + expect(outputs[0].address).toBe(SAMPLE_BTC_ADDRESS); + } + }, + TIMEOUT, + ); + + test( + 'fetches outputs with type filter', + async () => { + const outputs = await client.getOutputsByAddress( + SAMPLE_BTC_ADDRESS, + 'cardinal', + ); + expect(Array.isArray(outputs)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles invalid address', + async () => { + await expect( + client.getOutputsByAddress('invalid-address'), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getOutputsByAddress(SAMPLE_BTC_ADDRESS), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getRune', () => { + test( + 'fetches rune successfully', + async () => { + const rune = await client.getRune(SAMPLE_RUNE_NAME); + expect(rune.entry.spaced_rune).toBe(SAMPLE_RUNE_NAME); + expect(typeof rune.entry.block).toBe('number'); + expect(typeof rune.mintable).toBe('boolean'); + }, + TIMEOUT, + ); + + test( + 'handles invalid rune name', + async () => { + await expect(client.getRune('invalid/rune/name')).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getRune(SAMPLE_RUNE_NAME)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getLatestRunes', () => { + test( + 'fetches latest runes successfully', + async () => { + const response = await client.getLatestRunes(); + expect(Array.isArray(response.entries)).toBe(true); + expect(typeof response.more).toBe('boolean'); + if (response.entries.length > 0) { + const [runeName, runeData] = response.entries[0]; + expect(typeof runeName).toBe('string'); + expect(typeof runeData.block).toBe('number'); + } + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getLatestRunes()).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getRunesByPage', () => { + test( + 'fetches runes by page successfully', + async () => { + const response = await client.getRunesByPage(0); + expect(Array.isArray(response.entries)).toBe(true); + expect(typeof response.more).toBe('boolean'); + expect( + response.prev === null || typeof response.prev === 'number', + ).toBe(true); + expect( + response.next === null || typeof response.next === 'number', + ).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles invalid page number', + async () => { + await expect(client.getRunesByPage(-1)).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect( + invalidClient.getRunesByPage(1), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getSat', () => { + test( + 'fetches sat info successfully', + async () => { + const sat = await client.getSat(SAMPLE_SAT_NUMBER); + expect(sat.number).toBe(SAMPLE_SAT_NUMBER); + expect(Array.isArray(sat.charms)).toBe(true); + expect(Array.isArray(sat.inscriptions)).toBe(true); + expect(sat.decimal).toBeDefined(); + expect(sat.degree).toBeDefined(); + }, + TIMEOUT, + ); + + test( + 'rejects negative sat number', + async () => { + await expect(client.getSat(-1)).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getSat(SAMPLE_SAT_NUMBER)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getTx', () => { + test( + 'fetches transaction successfully', + async () => { + const tx = await client.getTx(SAMPLE_TX_ID); + expect(tx.txid).toBe(SAMPLE_TX_ID); + expect(Array.isArray(tx.transaction.input)).toBe(true); + expect(Array.isArray(tx.transaction.output)).toBe(true); + expect(typeof tx.inscription_count).toBe('number'); + }, + TIMEOUT, + ); + + test( + 'rejects invalid transaction id', + async () => { + await expect(client.getTx('invalid-txid')).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getTx(SAMPLE_TX_ID)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + test( + 'fetches status successfully', + async () => { + const status = await client.getStatus(); + expect(typeof status.height).toBe('number'); + expect(status.height).toBeGreaterThan(0); + expect(typeof status.chain).toBe('string'); + expect(typeof status.inscriptions).toBe('number'); + expect(typeof status.lost_sats).toBe('number'); + expect(typeof status.address_index).toBe('boolean'); + expect(typeof status.sat_index).toBe('boolean'); + expect(typeof status.rune_index).toBe('boolean'); + expect(typeof status.transaction_index).toBe('boolean'); + expect(status.initial_sync_time).toBeDefined(); + expect(status.uptime).toBeDefined(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + await expect(invalidClient.getStatus()).rejects.toThrow(); + }, + TIMEOUT, + ); }); diff --git a/src/test/unit/schemas.test.ts b/src/test/unit/schemas.test.ts index 74adfed..d5b3157 100644 --- a/src/test/unit/schemas.test.ts +++ b/src/test/unit/schemas.test.ts @@ -6,14 +6,18 @@ import { } from '../../schemas/block'; import { AddressInfoSchema, RuneBalanceSchema } from '../../schemas/address'; import { - InputSchema, - OutputSchema, - TransactionSchema, + TxInputSchema, + TxOutputSchema, + TxSchema, } from '../../schemas/transaction'; import { InscriptionSchema, InscriptionsResponseSchema, } from '../../schemas/inscription'; +import { OutputSchema } from '../../schemas/output'; +import { RuneSchema, RunesResponseSchema } from '../../schemas/rune'; +import { SatSchema } from '../../schemas/sat'; +import { StatusSchema } from '../../schemas/status'; import { GENESIS_BLOCK, SAMPLE_ADDRESS_INFO, @@ -24,32 +28,36 @@ import { SAMPLE_RUNE_BALANCE, SAMPLE_INSCRIPTION, SAMPLE_INSCRIPTIONS_RESPONSE, + SAMPLE_UTXO_INFO, + SAMPLE_RUNE, + SAMPLE_STATUS, + SAMPLE_SAT, } from '../data/test-data'; describe('Schema Validation', () => { - describe('Transaction Schemas', () => { - describe('InputSchema', () => { + describe('Tx Schemas', () => { + describe('TxInputSchema', () => { test('validates valid input', () => { - expect(InputSchema.safeParse(SAMPLE_INPUT).success).toBe(true); + expect(TxInputSchema.safeParse(SAMPLE_INPUT).success).toBe(true); }); test('rejects invalid sequence', () => { - const invalidInput = { + const invalidTxInput = { ...SAMPLE_INPUT, sequence: -1, }; - expect(InputSchema.safeParse(invalidInput).success).toBe(false); + expect(TxInputSchema.safeParse(invalidTxInput).success).toBe(false); }); test('rejects missing field', () => { - const { script_sig, ...invalidInput } = SAMPLE_INPUT; - expect(InputSchema.safeParse(invalidInput).success).toBe(false); + const { script_sig, ...invalidTxInput } = SAMPLE_INPUT; + expect(TxInputSchema.safeParse(invalidTxInput).success).toBe(false); }); }); describe('OutputSchema', () => { test('validates valid output', () => { - expect(OutputSchema.safeParse(SAMPLE_OUTPUT).success).toBe(true); + expect(TxOutputSchema.safeParse(SAMPLE_OUTPUT).success).toBe(true); }); test('rejects negative value', () => { @@ -57,20 +65,18 @@ describe('Schema Validation', () => { ...SAMPLE_OUTPUT, value: -5000000000, }; - expect(OutputSchema.safeParse(invalidOutput).success).toBe(false); + expect(TxOutputSchema.safeParse(invalidOutput).success).toBe(false); }); test('rejects missing script_pubkey', () => { const { script_pubkey, ...invalidOutput } = SAMPLE_OUTPUT; - expect(OutputSchema.safeParse(invalidOutput).success).toBe(false); + expect(TxOutputSchema.safeParse(invalidOutput).success).toBe(false); }); }); - describe('TransactionSchema', () => { + describe('TxSchema', () => { test('validates valid transaction', () => { - expect(TransactionSchema.safeParse(SAMPLE_TRANSACTION).success).toBe( - true, - ); + expect(TxSchema.safeParse(SAMPLE_TRANSACTION).success).toBe(true); }); test('rejects invalid version', () => { @@ -78,7 +84,7 @@ describe('Schema Validation', () => { ...SAMPLE_TRANSACTION, version: -1, }; - expect(TransactionSchema.safeParse(invalidTx).success).toBe(false); + expect(TxSchema.safeParse(invalidTx).success).toBe(false); }); test('rejects invalid input array', () => { @@ -86,7 +92,7 @@ describe('Schema Validation', () => { ...SAMPLE_TRANSACTION, input: [{ invalid: 'data' }], }; - expect(TransactionSchema.safeParse(invalidTx).success).toBe(false); + expect(TxSchema.safeParse(invalidTx).success).toBe(false); }); }); }); @@ -99,7 +105,7 @@ describe('Schema Validation', () => { }); test('rejects invalid length', () => { - const shortHash = GENESIS_BLOCK.hash.slice(0, -1); // Remove last character + const shortHash = GENESIS_BLOCK.hash.slice(0, -1); expect(BlockHashSchema.safeParse(shortHash).success).toBe(false); }); @@ -146,7 +152,7 @@ describe('Schema Validation', () => { test('rejects invalid block height type', () => { const invalidBlock = { ...GENESIS_BLOCK, - best_height: '864325', // string instead of number + best_height: '864325', }; expect(BlockSchema.safeParse(invalidBlock).success).toBe(false); }); @@ -218,7 +224,7 @@ describe('Schema Validation', () => { test('rejects invalid runes_balances format', () => { const invalidAddress = { ...SAMPLE_ADDRESS_INFO, - runes_balances: [['TEST•RUNE', 100, '🎯']], // number instead of string + runes_balances: [['TEST•RUNE', 100, '🎯']], }; expect(AddressInfoSchema.safeParse(invalidAddress).success).toBe(false); }); @@ -252,6 +258,27 @@ describe('Schema Validation', () => { expect(result.success).toBe(true); }); + test('validates all nullable fields', () => { + const nullableInscription = { + ...SAMPLE_INSCRIPTION, + address: null, + next: null, + previous: null, + rune: null, + sat: null, + metaprotocol: null, + }; + const result = InscriptionSchema.safeParse(nullableInscription); + expect(result.success).toBe(true); + }); + + test('validates without optional fields', () => { + const { children_count, metaprotocol, ...minimalInscription } = + SAMPLE_INSCRIPTION; + const result = InscriptionSchema.safeParse(minimalInscription); + expect(result.success).toBe(true); + }); + test('rejects negative values', () => { const inscriptionWithNegatives = { ...SAMPLE_INSCRIPTION, @@ -275,12 +302,213 @@ describe('Schema Validation', () => { test('rejects invalid types', () => { const invalidResponse = { SAMPLE_INSCRIPTIONS_RESPONSE, - ids: [123], // should be strings - page_index: -1, // should be non-negative + ids: [123], + page_index: -1, }; const result = InscriptionsResponseSchema.safeParse(invalidResponse); expect(result.success).toBe(false); }); }); }); + + describe('Output Schemas', () => { + describe('OutputSchema', () => { + test('validates valid output', () => { + const result = OutputSchema.safeParse(SAMPLE_UTXO_INFO); + expect(result.success).toBe(true); + }); + + test('rejects negative value', () => { + const invalidOutput = { + ...SAMPLE_UTXO_INFO, + value: -1, + }; + const result = OutputSchema.safeParse(invalidOutput); + expect(result.success).toBe(false); + }); + + test('validates without optional fields', () => { + const { runes, ...minimalOutput } = SAMPLE_UTXO_INFO; + const result = OutputSchema.safeParse(minimalOutput); + expect(result.success).toBe(true); + }); + + test('validates all nullable fields', () => { + const minimalOutput = { + ...SAMPLE_UTXO_INFO, + inscriptions: [], + runes: {}, + sat_ranges: null, + }; + const result = OutputSchema.safeParse(minimalOutput); + expect(result.success).toBe(true); + }); + + test('rejests invalid field types', () => { + const invalidOutput = { + ...SAMPLE_UTXO_INFO, + inscriptions: 'invalid', + runes: -1, + sat_ranges: [[0]], + }; + const result = OutputSchema.safeParse(invalidOutput); + expect(result.success).toBe(false); + }); + + test('rejects invalid sat ranges format', () => { + const invalidOutput = { + ...SAMPLE_UTXO_INFO, + sat_ranges: [[0]], + }; + const result = OutputSchema.safeParse(invalidOutput); + expect(result.success).toBe(false); + }); + }); + }); + + describe('Rune Schemas', () => { + describe('RuneSchema', () => { + test('validates valid rune', () => { + const result = RuneSchema.safeParse(SAMPLE_RUNE); + expect(result.success).toBe(true); + }); + + test('validates nullable field', () => { + const runeWithNullSymbol = { + ...SAMPLE_RUNE, + symbol: null, + }; + const result = RuneSchema.safeParse(runeWithNullSymbol); + expect(result.success).toBe(true); + }); + + test('fails with negative numbers', () => { + const invalidRune = { + ...SAMPLE_RUNE, + block: -1, + burned: -1, + mints: -1, + }; + const result = RuneSchema.safeParse(invalidRune); + expect(result.success).toBe(false); + }); + + test('validates RunesResponse structure', () => { + const validResponse = { + entries: [['TEST•RUNE', SAMPLE_RUNE]], + more: true, + prev: 1, + next: 3, + }; + const result = RunesResponseSchema.safeParse(validResponse); + expect(result.success).toBe(true); + }); + + test('validates RunesResponse with null values', () => { + const responseWithNulls = { + entries: [['TEST•RUNE', SAMPLE_RUNE]], + more: false, + prev: null, + next: null, + }; + const result = RunesResponseSchema.safeParse(responseWithNulls); + expect(result.success).toBe(true); + }); + }); + }); + + describe('Sat Schema', () => { + describe('SatSchema', () => { + test('validates valid sat', () => { + const result = SatSchema.safeParse(SAMPLE_SAT); + expect(result.success).toBe(true); + }); + + test('validates nullable fields', () => { + const satWithNullSatpoint = { + ...SAMPLE_SAT, + satpoint: null, + }; + const result = SatSchema.safeParse(satWithNullSatpoint); + expect(result.success).toBe(true); + }); + + test('rejects invalid charm value', () => { + const satWithInvalidCharm = { + ...SAMPLE_SAT, + charms: ['invalid_charm'], + }; + const result = SatSchema.safeParse(satWithInvalidCharm); + expect(result.success).toBe(false); + }); + + test('rejects invalid rarity value', () => { + const satWithInvalidRarity = { + ...SAMPLE_SAT, + rarity: 'invalid_rarity', + }; + const result = SatSchema.safeParse(satWithInvalidRarity); + expect(result.success).toBe(false); + }); + + test('rejects negative values', () => { + const satWithNegatives = { + ...SAMPLE_SAT, + number: -1, + offset: -1, + period: -1, + }; + const result = SatSchema.safeParse(satWithNegatives); + expect(result.success).toBe(false); + }); + }); + }); + + describe('Status Schema', () => { + describe('StatusSchema', () => { + test('validates valid status', () => { + const result = StatusSchema.safeParse(SAMPLE_STATUS); + expect(result.success).toBe(true); + }); + + test('validates nullable fields', () => { + const statusWithNullRune = { + ...SAMPLE_STATUS, + minimum_rune_for_next_block: null, + }; + const result = StatusSchema.safeParse(statusWithNullRune); + expect(result.success).toBe(true); + }); + + test('validates time fields', () => { + const statusWithMinimalTime = { + ...SAMPLE_STATUS, + initial_sync_time: { secs: 0, nanos: 0 }, + uptime: { secs: 0, nanos: 0 }, + }; + const result = StatusSchema.safeParse(statusWithMinimalTime); + expect(result.success).toBe(true); + }); + + test('rejects negative values', () => { + const statusWithNegatives = { + ...SAMPLE_STATUS, + height: -1, + inscriptions: -1, + lost_sats: -1, + }; + const result = StatusSchema.safeParse(statusWithNegatives); + expect(result.success).toBe(false); + }); + + test('rejects invalid time fields', () => { + const statusWithInvalidTime = { + ...SAMPLE_STATUS, + initial_sync_time: { secs: -1, nanos: -1 }, + }; + const result = StatusSchema.safeParse(statusWithInvalidTime); + expect(result.success).toBe(false); + }); + }); + }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 2287ec8..15d3874 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,18 +1,47 @@ -import { z } from 'zod'; -import { +import type { z } from 'zod'; +import type { BlockSchema, BlockHashSchema, BlocksResponseSchema, - TransactionSchema, + TxSchema, + TxInputSchema, AddressInfoSchema, InscriptionSchema, + CharmSchema, InscriptionsResponseSchema, -} from 'schemas'; + OutputSchema, + RuneSchema, + RunesResponseSchema, + SatSchema, + StatusSchema, + RuneResponseSchema, + TxDetailsSchema, + RaritySchema, + TxOutputSchema, + OutputTypeSchema, +} from '../schemas'; -export type Block = z.infer; +export type AddressInfo = z.infer; + +export type BlockInfo = z.infer; export type BlockHash = z.infer; export type BlocksResponse = z.infer; -export type Transaction = z.infer; -export type AddressInfo = z.infer; -export type Inscription = z.infer; + +export type Transaction = z.infer; +export type TransactionInfo = z.infer; +export type TransactionInput = z.infer; +export type TransactionOutput = z.infer; + +export type SatInfo = z.infer; +export type CharmType = z.infer; +export type RarityType = z.infer; +export type OutputType = z.infer; +export type OutputInfo = z.infer; +export type InscriptionInfo = z.infer; export type InscriptionsResponse = z.infer; + +export type RuneInfo = z.infer; +export type RuneResponse = z.infer; +export type RunesResponse = z.infer; + +export type Status = z.infer; diff --git a/tsconfig.json b/tsconfig.json index c9c25c7..cb8d60c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,21 +4,14 @@ "module": "esnext", "moduleResolution": "bundler", "types": ["bun-types"], - "baseUrl": ".", - "paths": { - "src": ["src/*"], - "schemas": ["src/schemas"], - "schemas/*": ["src/schemas/*"], - "types": ["src/types"], - "types/*": ["src/types/*"], - }, "esModuleInterop": true, "strict": true, "skipLibCheck": true, "declaration": true, - "outDir": "./dist", - "rootDir": "./src" + "isolatedModules": true, + "rootDir": "./src", + "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] -} +} \ No newline at end of file From 966dc243378f19aa47c31ae00db8d748fcf91fb0 Mon Sep 17 00:00:00 2001 From: gmemez Date: Thu, 30 Jan 2025 22:26:09 +0700 Subject: [PATCH 3/7] amend --- src/test/data/test-data.ts | 4 ++-- src/test/integration/api.test.ts | 4 +--- src/test/unit/schemas.test.ts | 20 +------------------- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/test/data/test-data.ts b/src/test/data/test-data.ts index c88d39d..4d86fb0 100644 --- a/src/test/data/test-data.ts +++ b/src/test/data/test-data.ts @@ -51,7 +51,7 @@ export const SAMPLE_ADDRESS_INFO = { ['GREED•FRAGMENTS', '417', '∞'], ['LIQUIDIUM•TOKEN', '117.42', '🫠'], ['EPIC•EPIC•EPIC•EPIC', '20000', '💥'], - ] + ], }; export const SAMPLE_RUNE_BALANCE = ['TEST•RUNE', '100', '🎯']; @@ -75,7 +75,7 @@ export const SAMPLE_OUTPOINT_A = export const SAMPLE_OUTPOINT_B = '85abae61cf0f7f90efc67ab5059e6ee3e600c3015ea68e9b33e945d8555766ed:100'; - + export const SAMPLE_BTC_ADDRESS = '358mMRwcxuCSkKheuVWaXHJBGKrXo3f6JW'; export const SAMPLE_TX_ID = diff --git a/src/test/integration/api.test.ts b/src/test/integration/api.test.ts index 694559a..55a50db 100644 --- a/src/test/integration/api.test.ts +++ b/src/test/integration/api.test.ts @@ -558,9 +558,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - await expect( - invalidClient.getRunesByPage(1), - ).rejects.toThrow(); + await expect(invalidClient.getRunesByPage(1)).rejects.toThrow(); }, TIMEOUT, ); diff --git a/src/test/unit/schemas.test.ts b/src/test/unit/schemas.test.ts index d5b3157..cda986e 100644 --- a/src/test/unit/schemas.test.ts +++ b/src/test/unit/schemas.test.ts @@ -4,7 +4,7 @@ import { BlockHashSchema, BlocksResponseSchema, } from '../../schemas/block'; -import { AddressInfoSchema, RuneBalanceSchema } from '../../schemas/address'; +import { AddressInfoSchema } from '../../schemas/address'; import { TxInputSchema, TxOutputSchema, @@ -176,24 +176,6 @@ describe('Schema Validation', () => { }); describe('Address Schemas', () => { - describe('RuneBalanceSchema', () => { - test('validates valid rune balance', () => { - expect(RuneBalanceSchema.safeParse(SAMPLE_RUNE_BALANCE).success).toBe( - true, - ); - }); - - test('rejects invalid tuple length', () => { - const invalidBalance = ['TEST•RUNE', '100']; - expect(RuneBalanceSchema.safeParse(invalidBalance).success).toBe(false); - }); - - test('rejects invalid value type', () => { - const invalidBalance = ['TEST•RUNE', 100, '🎯']; - expect(RuneBalanceSchema.safeParse(invalidBalance).success).toBe(false); - }); - }); - describe('AddressInfoSchema', () => { test('validates valid address info', () => { expect(AddressInfoSchema.safeParse(SAMPLE_ADDRESS_INFO).success).toBe( From 4a2fc2c2284ef9cd27a4999c002c94d027bfc154 Mon Sep 17 00:00:00 2001 From: raphjaph Date: Fri, 31 Jan 2025 15:36:23 +0100 Subject: [PATCH 4/7] Some nits --- src/client.ts | 26 ++++++------- src/schemas/block.ts | 6 +-- src/schemas/output.ts | 2 +- src/schemas/status.ts | 2 +- src/schemas/transaction.ts | 14 +++---- src/test/integration/api.test.ts | 10 ++--- src/test/unit/schemas.test.ts | 66 ++++++++++++++++---------------- src/types/index.ts | 28 +++++++------- 8 files changed, 77 insertions(+), 77 deletions(-) diff --git a/src/client.ts b/src/client.ts index e4f868f..b253c7d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,18 @@ import { z } from 'zod'; import api from './api'; import { - BlockSchema, + BlockInfoSchema, BlockHashSchema, AddressInfoSchema, BlocksResponseSchema, InscriptionSchema, InscriptionsResponseSchema, - OutputSchema, + OutputInfoSchema, RuneResponseSchema, RunesResponseSchema, SatSchema, - StatusSchema, - TxDetailsSchema, + ServerStatusSchema, + TransactionInfoSchema, } from './schemas'; import type { BlockInfo, @@ -26,7 +26,7 @@ import type { RunesResponse, SatInfo, TransactionInfo, - Status, + ServerStatus, OutputType, } from './types'; @@ -110,7 +110,7 @@ export class OrdClient { } async getBlock(heightOrHash: number | BlockHash): Promise { - return this.fetch(api.block(heightOrHash), BlockSchema); + return this.fetch(api.block(heightOrHash), BlockInfoSchema); } async getBlockCount(): Promise { @@ -175,11 +175,11 @@ export class OrdClient { } async getOutput(outpoint: string): Promise { - return this.fetch(api.output(outpoint), OutputSchema); + return this.fetch(api.output(outpoint), OutputInfoSchema); } async getOutputs(outpoints: string[]): Promise { - return this.fetchPost(api.outputs.base, outpoints, z.array(OutputSchema)); + return this.fetchPost(api.outputs.base, outpoints, z.array(OutputInfoSchema)); } async getOutputsByAddress( @@ -188,7 +188,7 @@ export class OrdClient { ): Promise { return this.fetch( api.outputs.byAddress(address, type), - z.array(OutputSchema), + z.array(OutputInfoSchema), ); } @@ -208,11 +208,11 @@ export class OrdClient { return this.fetch(api.sat(number), SatSchema); } - async getTx(txId: string): Promise { - return this.fetch(api.tx(txId), TxDetailsSchema); + async getTransaction(txId: string): Promise { + return this.fetch(api.tx(txId), TransactionInfoSchema); } - async getStatus(): Promise { - return this.fetch(api.status, StatusSchema); + async getServerStatus(): Promise { + return this.fetch(api.status, ServerStatusSchema); } } diff --git a/src/schemas/block.ts b/src/schemas/block.ts index 15d549e..ddd17d9 100644 --- a/src/schemas/block.ts +++ b/src/schemas/block.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { TxSchema } from './transaction'; +import { TransactionSchema } from './transaction'; const isHexString = (str: string) => /^[0-9a-fA-F]+$/.test(str); @@ -11,14 +11,14 @@ export const BlockHashSchema = z 'Block hash must contain only hexadecimal characters (0-9, a-f, A-F)', ); -export const BlockSchema = z.object({ +export const BlockInfoSchema = z.object({ best_height: z.number().int().nonnegative(), hash: BlockHashSchema, height: z.number().int().nonnegative(), inscriptions: z.array(z.string()), runes: z.array(z.string()), target: z.string(), - transactions: z.array(TxSchema), + transactions: z.array(TransactionSchema), }); export const BlocksResponseSchema = z.object({ diff --git a/src/schemas/output.ts b/src/schemas/output.ts index 52ed100..5b72e6f 100644 --- a/src/schemas/output.ts +++ b/src/schemas/output.ts @@ -18,7 +18,7 @@ const RuneSchema = z.object({ symbol: z.string(), }); -export const OutputSchema = z.object({ +export const OutputInfoSchema = z.object({ address: z.string(), indexed: z.boolean(), inscriptions: z.array(z.string()), diff --git a/src/schemas/status.ts b/src/schemas/status.ts index 06f19d8..4663d77 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -5,7 +5,7 @@ const TimeSchema = z.object({ nanos: z.number().int().nonnegative(), }); -export const StatusSchema = z.object({ +export const ServerStatusSchema = z.object({ address_index: z.boolean(), blessed_inscriptions: z.number().int().nonnegative(), chain: z.string(), diff --git a/src/schemas/transaction.ts b/src/schemas/transaction.ts index 1857862..dccf0e6 100644 --- a/src/schemas/transaction.ts +++ b/src/schemas/transaction.ts @@ -1,28 +1,28 @@ import { z } from 'zod'; -export const TxInputSchema = z.object({ +export const InputSchema = z.object({ previous_output: z.string(), script_sig: z.string(), sequence: z.number().int().nonnegative(), witness: z.array(z.string()), }); -export const TxOutputSchema = z.object({ +export const OutputSchema = z.object({ value: z.number().int().nonnegative(), script_pubkey: z.string(), }); -export const TxSchema = z.object({ +export const TransactionSchema = z.object({ version: z.number().int().nonnegative(), lock_time: z.number().int().nonnegative(), - input: z.array(TxInputSchema), - output: z.array(TxOutputSchema), + input: z.array(InputSchema), + output: z.array(OutputSchema), }); -export const TxDetailsSchema = z.object({ +export const TransactionInfoSchema = z.object({ chain: z.string(), etching: z.string().nullable(), inscription_count: z.number().int().nonnegative(), - transaction: TxSchema, + transaction: TransactionSchema, txid: z.string(), }); diff --git a/src/test/integration/api.test.ts b/src/test/integration/api.test.ts index 55a50db..5847cf7 100644 --- a/src/test/integration/api.test.ts +++ b/src/test/integration/api.test.ts @@ -599,7 +599,7 @@ describe('API Integration Tests', () => { test( 'fetches transaction successfully', async () => { - const tx = await client.getTx(SAMPLE_TX_ID); + const tx = await client.getTransaction(SAMPLE_TX_ID); expect(tx.txid).toBe(SAMPLE_TX_ID); expect(Array.isArray(tx.transaction.input)).toBe(true); expect(Array.isArray(tx.transaction.output)).toBe(true); @@ -611,7 +611,7 @@ describe('API Integration Tests', () => { test( 'rejects invalid transaction id', async () => { - await expect(client.getTx('invalid-txid')).rejects.toThrow(); + expect(client.getTransaction('invalid-txid')).rejects.toThrow(); }, TIMEOUT, ); @@ -619,7 +619,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - await expect(invalidClient.getTx(SAMPLE_TX_ID)).rejects.toThrow(); + expect(invalidClient.getTransaction(SAMPLE_TX_ID)).rejects.toThrow(); }, TIMEOUT, ); @@ -628,7 +628,7 @@ describe('API Integration Tests', () => { test( 'fetches status successfully', async () => { - const status = await client.getStatus(); + const status = await client.getServerStatus(); expect(typeof status.height).toBe('number'); expect(status.height).toBeGreaterThan(0); expect(typeof status.chain).toBe('string'); @@ -647,7 +647,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - await expect(invalidClient.getStatus()).rejects.toThrow(); + expect(invalidClient.getServerStatus()).rejects.toThrow(); }, TIMEOUT, ); diff --git a/src/test/unit/schemas.test.ts b/src/test/unit/schemas.test.ts index cda986e..5272cd6 100644 --- a/src/test/unit/schemas.test.ts +++ b/src/test/unit/schemas.test.ts @@ -1,23 +1,23 @@ import { expect, test, describe } from 'bun:test'; import { - BlockSchema, + BlockInfoSchema, BlockHashSchema, BlocksResponseSchema, } from '../../schemas/block'; import { AddressInfoSchema } from '../../schemas/address'; import { - TxInputSchema, - TxOutputSchema, - TxSchema, + InputSchema, + OutputSchema, + TransactionSchema, } from '../../schemas/transaction'; import { InscriptionSchema, InscriptionsResponseSchema, } from '../../schemas/inscription'; -import { OutputSchema } from '../../schemas/output'; +import { OutputInfoSchema } from '../../schemas/output'; import { RuneSchema, RunesResponseSchema } from '../../schemas/rune'; import { SatSchema } from '../../schemas/sat'; -import { StatusSchema } from '../../schemas/status'; +import { ServerStatusSchema } from '../../schemas/status'; import { GENESIS_BLOCK, SAMPLE_ADDRESS_INFO, @@ -38,7 +38,7 @@ describe('Schema Validation', () => { describe('Tx Schemas', () => { describe('TxInputSchema', () => { test('validates valid input', () => { - expect(TxInputSchema.safeParse(SAMPLE_INPUT).success).toBe(true); + expect(InputSchema.safeParse(SAMPLE_INPUT).success).toBe(true); }); test('rejects invalid sequence', () => { @@ -46,18 +46,18 @@ describe('Schema Validation', () => { ...SAMPLE_INPUT, sequence: -1, }; - expect(TxInputSchema.safeParse(invalidTxInput).success).toBe(false); + expect(InputSchema.safeParse(invalidTxInput).success).toBe(false); }); test('rejects missing field', () => { const { script_sig, ...invalidTxInput } = SAMPLE_INPUT; - expect(TxInputSchema.safeParse(invalidTxInput).success).toBe(false); + expect(InputSchema.safeParse(invalidTxInput).success).toBe(false); }); }); describe('OutputSchema', () => { test('validates valid output', () => { - expect(TxOutputSchema.safeParse(SAMPLE_OUTPUT).success).toBe(true); + expect(OutputSchema.safeParse(SAMPLE_OUTPUT).success).toBe(true); }); test('rejects negative value', () => { @@ -65,18 +65,18 @@ describe('Schema Validation', () => { ...SAMPLE_OUTPUT, value: -5000000000, }; - expect(TxOutputSchema.safeParse(invalidOutput).success).toBe(false); + expect(OutputSchema.safeParse(invalidOutput).success).toBe(false); }); test('rejects missing script_pubkey', () => { const { script_pubkey, ...invalidOutput } = SAMPLE_OUTPUT; - expect(TxOutputSchema.safeParse(invalidOutput).success).toBe(false); + expect(OutputSchema.safeParse(invalidOutput).success).toBe(false); }); }); describe('TxSchema', () => { test('validates valid transaction', () => { - expect(TxSchema.safeParse(SAMPLE_TRANSACTION).success).toBe(true); + expect(TransactionSchema.safeParse(SAMPLE_TRANSACTION).success).toBe(true); }); test('rejects invalid version', () => { @@ -84,7 +84,7 @@ describe('Schema Validation', () => { ...SAMPLE_TRANSACTION, version: -1, }; - expect(TxSchema.safeParse(invalidTx).success).toBe(false); + expect(TransactionSchema.safeParse(invalidTx).success).toBe(false); }); test('rejects invalid input array', () => { @@ -92,7 +92,7 @@ describe('Schema Validation', () => { ...SAMPLE_TRANSACTION, input: [{ invalid: 'data' }], }; - expect(TxSchema.safeParse(invalidTx).success).toBe(false); + expect(TransactionSchema.safeParse(invalidTx).success).toBe(false); }); }); }); @@ -146,7 +146,7 @@ describe('Schema Validation', () => { describe('BlockSchema', () => { test('validates valid block', () => { - expect(BlockSchema.safeParse(GENESIS_BLOCK).success).toBe(true); + expect(BlockInfoSchema.safeParse(GENESIS_BLOCK).success).toBe(true); }); test('rejects invalid block height type', () => { @@ -154,7 +154,7 @@ describe('Schema Validation', () => { ...GENESIS_BLOCK, best_height: '864325', }; - expect(BlockSchema.safeParse(invalidBlock).success).toBe(false); + expect(BlockInfoSchema.safeParse(invalidBlock).success).toBe(false); }); test('rejects invalid block hash', () => { @@ -162,7 +162,7 @@ describe('Schema Validation', () => { ...GENESIS_BLOCK, hash: 'invalid_hash', }; - expect(BlockSchema.safeParse(invalidBlock).success).toBe(false); + expect(BlockInfoSchema.safeParse(invalidBlock).success).toBe(false); }); test('rejects negative height', () => { @@ -170,7 +170,7 @@ describe('Schema Validation', () => { ...GENESIS_BLOCK, height: -1, }; - expect(BlockSchema.safeParse(invalidBlock).success).toBe(false); + expect(BlockInfoSchema.safeParse(invalidBlock).success).toBe(false); }); }); }); @@ -293,10 +293,10 @@ describe('Schema Validation', () => { }); }); - describe('Output Schemas', () => { - describe('OutputSchema', () => { + describe('Output Info Schemas', () => { + describe('OutputInfoSchema', () => { test('validates valid output', () => { - const result = OutputSchema.safeParse(SAMPLE_UTXO_INFO); + const result = OutputInfoSchema.safeParse(SAMPLE_UTXO_INFO); expect(result.success).toBe(true); }); @@ -305,13 +305,13 @@ describe('Schema Validation', () => { ...SAMPLE_UTXO_INFO, value: -1, }; - const result = OutputSchema.safeParse(invalidOutput); + const result = OutputInfoSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); test('validates without optional fields', () => { const { runes, ...minimalOutput } = SAMPLE_UTXO_INFO; - const result = OutputSchema.safeParse(minimalOutput); + const result = OutputInfoSchema.safeParse(minimalOutput); expect(result.success).toBe(true); }); @@ -322,7 +322,7 @@ describe('Schema Validation', () => { runes: {}, sat_ranges: null, }; - const result = OutputSchema.safeParse(minimalOutput); + const result = OutputInfoSchema.safeParse(minimalOutput); expect(result.success).toBe(true); }); @@ -333,7 +333,7 @@ describe('Schema Validation', () => { runes: -1, sat_ranges: [[0]], }; - const result = OutputSchema.safeParse(invalidOutput); + const result = OutputInfoSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); @@ -342,7 +342,7 @@ describe('Schema Validation', () => { ...SAMPLE_UTXO_INFO, sat_ranges: [[0]], }; - const result = OutputSchema.safeParse(invalidOutput); + const result = OutputInfoSchema.safeParse(invalidOutput); expect(result.success).toBe(false); }); }); @@ -447,9 +447,9 @@ describe('Schema Validation', () => { }); describe('Status Schema', () => { - describe('StatusSchema', () => { + describe('ServerStatusSchema', () => { test('validates valid status', () => { - const result = StatusSchema.safeParse(SAMPLE_STATUS); + const result = ServerStatusSchema.safeParse(SAMPLE_STATUS); expect(result.success).toBe(true); }); @@ -458,7 +458,7 @@ describe('Schema Validation', () => { ...SAMPLE_STATUS, minimum_rune_for_next_block: null, }; - const result = StatusSchema.safeParse(statusWithNullRune); + const result = ServerStatusSchema.safeParse(statusWithNullRune); expect(result.success).toBe(true); }); @@ -468,7 +468,7 @@ describe('Schema Validation', () => { initial_sync_time: { secs: 0, nanos: 0 }, uptime: { secs: 0, nanos: 0 }, }; - const result = StatusSchema.safeParse(statusWithMinimalTime); + const result = ServerStatusSchema.safeParse(statusWithMinimalTime); expect(result.success).toBe(true); }); @@ -479,7 +479,7 @@ describe('Schema Validation', () => { inscriptions: -1, lost_sats: -1, }; - const result = StatusSchema.safeParse(statusWithNegatives); + const result = ServerStatusSchema.safeParse(statusWithNegatives); expect(result.success).toBe(false); }); @@ -488,7 +488,7 @@ describe('Schema Validation', () => { ...SAMPLE_STATUS, initial_sync_time: { secs: -1, nanos: -1 }, }; - const result = StatusSchema.safeParse(statusWithInvalidTime); + const result = ServerStatusSchema.safeParse(statusWithInvalidTime); expect(result.success).toBe(false); }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 15d3874..29166ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,42 +1,42 @@ import type { z } from 'zod'; import type { - BlockSchema, + BlockInfoSchema, BlockHashSchema, BlocksResponseSchema, - TxSchema, - TxInputSchema, + TransactionSchema, + InputSchema, AddressInfoSchema, InscriptionSchema, CharmSchema, InscriptionsResponseSchema, - OutputSchema, + OutputInfoSchema, RuneSchema, RunesResponseSchema, SatSchema, - StatusSchema, + ServerStatusSchema, RuneResponseSchema, - TxDetailsSchema, + TransactionInfoSchema, RaritySchema, - TxOutputSchema, + OutputSchema, OutputTypeSchema, } from '../schemas'; export type AddressInfo = z.infer; -export type BlockInfo = z.infer; +export type BlockInfo = z.infer; export type BlockHash = z.infer; export type BlocksResponse = z.infer; -export type Transaction = z.infer; -export type TransactionInfo = z.infer; -export type TransactionInput = z.infer; -export type TransactionOutput = z.infer; +export type Transaction = z.infer; +export type TransactionInfo = z.infer; +export type Input = z.infer; +export type Output = z.infer; export type SatInfo = z.infer; export type CharmType = z.infer; export type RarityType = z.infer; export type OutputType = z.infer; -export type OutputInfo = z.infer; +export type OutputInfo = z.infer; export type InscriptionInfo = z.infer; export type InscriptionsResponse = z.infer; @@ -44,4 +44,4 @@ export type RuneInfo = z.infer; export type RuneResponse = z.infer; export type RunesResponse = z.infer; -export type Status = z.infer; +export type ServerStatus = z.infer; From f8a6118346f251ef5917932878e06c81f1d46e64 Mon Sep 17 00:00:00 2001 From: gmemez Date: Fri, 31 Jan 2025 22:19:38 +0700 Subject: [PATCH 5/7] nits --- src/client.ts | 6 +++++- src/schemas/address.ts | 4 ++-- src/schemas/inscription.ts | 4 ++-- src/schemas/output.ts | 6 +++--- src/schemas/sat.ts | 1 + src/test/data/test-data.ts | 3 ++- src/test/integration/api.test.ts | 2 +- src/test/unit/schemas.test.ts | 18 ++++-------------- 8 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/client.ts b/src/client.ts index b253c7d..9ac5448 100644 --- a/src/client.ts +++ b/src/client.ts @@ -179,7 +179,11 @@ export class OrdClient { } async getOutputs(outpoints: string[]): Promise { - return this.fetchPost(api.outputs.base, outpoints, z.array(OutputInfoSchema)); + return this.fetchPost( + api.outputs.base, + outpoints, + z.array(OutputInfoSchema), + ); } async getOutputsByAddress( diff --git a/src/schemas/address.ts b/src/schemas/address.ts index 3389e8f..f5480a3 100644 --- a/src/schemas/address.ts +++ b/src/schemas/address.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const AddressInfoSchema = z.object({ outputs: z.array(z.string()), - inscriptions: z.array(z.string()), + inscriptions: z.array(z.string()).nullable(), sat_balance: z.number().int().nonnegative(), - runes_balances: z.array(z.array(z.string())), + runes_balances: z.array(z.array(z.string())).nullable(), }); diff --git a/src/schemas/inscription.ts b/src/schemas/inscription.ts index 1a82300..e956856 100644 --- a/src/schemas/inscription.ts +++ b/src/schemas/inscription.ts @@ -20,7 +20,7 @@ export const CharmSchema = z.enum([ export const InscriptionSchema = z.object({ address: z.string().nullable(), charms: z.array(CharmSchema), - children_count: z.number().int().nonnegative().nullable().optional(), + child_count: z.number().int().nonnegative(), children: z.array(z.string()), content_length: z.number().int().nonnegative(), content_type: z.string(), @@ -37,7 +37,7 @@ export const InscriptionSchema = z.object({ satpoint: z.string(), timestamp: z.number().int(), value: z.number().int().nonnegative(), - metaprotocol: z.string().nullable().optional(), + metaprotocol: z.string().nullable(), }); export const InscriptionsResponseSchema = z.object({ diff --git a/src/schemas/output.ts b/src/schemas/output.ts index 5b72e6f..9e0c589 100644 --- a/src/schemas/output.ts +++ b/src/schemas/output.ts @@ -19,11 +19,11 @@ const RuneSchema = z.object({ }); export const OutputInfoSchema = z.object({ - address: z.string(), + address: z.string().nullable(), indexed: z.boolean(), - inscriptions: z.array(z.string()), + inscriptions: z.array(z.string()).nullable(), outpoint: z.string(), - runes: z.record(z.string(), RuneSchema).optional(), + runes: z.record(z.string(), RuneSchema).nullable(), sat_ranges: z.array(SatRangeSchema).nullable(), script_pubkey: z.string(), spent: z.boolean(), diff --git a/src/schemas/sat.ts b/src/schemas/sat.ts index 4543645..f8a29ba 100644 --- a/src/schemas/sat.ts +++ b/src/schemas/sat.ts @@ -11,6 +11,7 @@ export const RaritySchema = z.enum([ ]); export const SatSchema = z.object({ + address: z.string().nullable(), block: z.number().int().nonnegative(), charms: z.array(CharmSchema), cycle: z.number().int().nonnegative(), diff --git a/src/test/data/test-data.ts b/src/test/data/test-data.ts index 4d86fb0..58aec5c 100644 --- a/src/test/data/test-data.ts +++ b/src/test/data/test-data.ts @@ -119,7 +119,7 @@ export const SAMPLE_INSCRIPTION = { '681b5373c03e3f819231afd9227f54101395299c9e58356bda278e2f32bef2cdi0', 'b1ef66c2d1a047cbaa6260b74daac43813924378fe08ef8545da4cb79e8fcf00i0', ], - children_count: 2, + child_count: 2, content_length: 793, content_type: 'image/png', effective_content_type: 'image/png', @@ -204,6 +204,7 @@ export const SAMPLE_RUNE_NAME = 'MEMENTO•MORI'; export const SAMPLE_SAT_NUMBER = 2099994106992659; export const SAMPLE_SAT = { + address: 'bc1ptest', block: 1000, charms: ['uncommon', 'cursed'], cycle: 0, diff --git a/src/test/integration/api.test.ts b/src/test/integration/api.test.ts index 5847cf7..4d999bc 100644 --- a/src/test/integration/api.test.ts +++ b/src/test/integration/api.test.ts @@ -611,7 +611,7 @@ describe('API Integration Tests', () => { test( 'rejects invalid transaction id', async () => { - expect(client.getTransaction('invalid-txid')).rejects.toThrow(); + expect(client.getTransaction('invalid-txid')).rejects.toThrow(); }, TIMEOUT, ); diff --git a/src/test/unit/schemas.test.ts b/src/test/unit/schemas.test.ts index 5272cd6..5f11a27 100644 --- a/src/test/unit/schemas.test.ts +++ b/src/test/unit/schemas.test.ts @@ -76,7 +76,9 @@ describe('Schema Validation', () => { describe('TxSchema', () => { test('validates valid transaction', () => { - expect(TransactionSchema.safeParse(SAMPLE_TRANSACTION).success).toBe(true); + expect(TransactionSchema.safeParse(SAMPLE_TRANSACTION).success).toBe( + true, + ); }); test('rejects invalid version', () => { @@ -254,13 +256,6 @@ describe('Schema Validation', () => { expect(result.success).toBe(true); }); - test('validates without optional fields', () => { - const { children_count, metaprotocol, ...minimalInscription } = - SAMPLE_INSCRIPTION; - const result = InscriptionSchema.safeParse(minimalInscription); - expect(result.success).toBe(true); - }); - test('rejects negative values', () => { const inscriptionWithNegatives = { ...SAMPLE_INSCRIPTION, @@ -309,12 +304,6 @@ describe('Schema Validation', () => { expect(result.success).toBe(false); }); - test('validates without optional fields', () => { - const { runes, ...minimalOutput } = SAMPLE_UTXO_INFO; - const result = OutputInfoSchema.safeParse(minimalOutput); - expect(result.success).toBe(true); - }); - test('validates all nullable fields', () => { const minimalOutput = { ...SAMPLE_UTXO_INFO, @@ -409,6 +398,7 @@ describe('Schema Validation', () => { test('validates nullable fields', () => { const satWithNullSatpoint = { ...SAMPLE_SAT, + address: null, satpoint: null, }; const result = SatSchema.safeParse(satWithNullSatpoint); From 6f67261b586ad6e87c97b9172ea8d5a8859ac719 Mon Sep 17 00:00:00 2001 From: gmemez Date: Fri, 31 Jan 2025 22:26:40 +0700 Subject: [PATCH 6/7] nit --- src/schemas/rune.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/rune.ts b/src/schemas/rune.ts index 3c421a9..99ee83a 100644 --- a/src/schemas/rune.ts +++ b/src/schemas/rune.ts @@ -5,7 +5,7 @@ export const RuneTermsSchema = z.object({ cap: z.number().int().nonnegative(), height: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), offset: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), -}); +}).nullable(); export const RuneSchema = z.object({ block: z.number().int().nonnegative(), From 029817ffe65ce008376c992416c8ee2c706cf541 Mon Sep 17 00:00:00 2001 From: gmemez Date: Fri, 31 Jan 2025 22:28:46 +0700 Subject: [PATCH 7/7] inscription nullable --- src/schemas/inscription.ts | 10 +++++----- src/schemas/rune.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/schemas/inscription.ts b/src/schemas/inscription.ts index e956856..d332f34 100644 --- a/src/schemas/inscription.ts +++ b/src/schemas/inscription.ts @@ -22,13 +22,13 @@ export const InscriptionSchema = z.object({ charms: z.array(CharmSchema), child_count: z.number().int().nonnegative(), children: z.array(z.string()), - content_length: z.number().int().nonnegative(), - content_type: z.string(), - effective_content_type: z.string(), + content_length: z.number().int().nonnegative().nullable(), + content_type: z.string().nullable(), + effective_content_type: z.string().nullable(), fee: z.number().int().nonnegative(), height: z.number().int().nonnegative(), id: z.string(), - next: z.string().nullable(), + next: z.string().nullable().nullable(), number: z.number().int().nonnegative(), parents: z.array(z.string()), previous: z.string().nullable(), @@ -36,7 +36,7 @@ export const InscriptionSchema = z.object({ sat: z.number().int().nonnegative().nullable(), satpoint: z.string(), timestamp: z.number().int(), - value: z.number().int().nonnegative(), + value: z.number().int().nonnegative().nullable(), metaprotocol: z.string().nullable(), }); diff --git a/src/schemas/rune.ts b/src/schemas/rune.ts index 99ee83a..257ff1d 100644 --- a/src/schemas/rune.ts +++ b/src/schemas/rune.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; -export const RuneTermsSchema = z.object({ - amount: z.number().int().nonnegative(), - cap: z.number().int().nonnegative(), - height: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), - offset: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), -}).nullable(); +export const RuneTermsSchema = z + .object({ + amount: z.number().int().nonnegative(), + cap: z.number().int().nonnegative(), + height: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), + offset: z.tuple([z.number().int().nullable(), z.number().int().nullable()]), + }) + .nullable(); export const RuneSchema = z.object({ block: z.number().int().nonnegative(),