From e4020a996cd7191e145149c1258b64b45818c3c9 Mon Sep 17 00:00:00 2001 From: devperate Date: Thu, 18 Sep 2025 13:09:11 +0000 Subject: [PATCH] Resolve "BCMR Adapter: Create BaseBCMR and HTTP implementation" --- packages/stack/src/bcmr/base-bcmr.ts | 42 ++++++ packages/stack/src/bcmr/bcmr-composite.ts | 25 ++++ packages/stack/src/bcmr/bcmr-helpers.ts | 24 ++++ packages/stack/src/bcmr/bcmr-http.ts | 130 ++++++++++++++++++ packages/stack/src/bcmr/index.ts | 3 + .../src/blockchain/blockchain-chaingraph.ts | 14 +- packages/stack/src/index.ts | 3 + packages/stack/src/storage/base-store.ts | 21 +-- packages/stack/src/utils/misc.ts | 72 ++++++++++ .../stack/tests/bcmr/bcmr-composite.test.ts | 46 +++++++ packages/stack/tsconfig.json | 2 +- 11 files changed, 363 insertions(+), 19 deletions(-) create mode 100644 packages/stack/src/bcmr/base-bcmr.ts create mode 100644 packages/stack/src/bcmr/bcmr-composite.ts create mode 100644 packages/stack/src/bcmr/bcmr-helpers.ts create mode 100644 packages/stack/src/bcmr/bcmr-http.ts create mode 100644 packages/stack/src/bcmr/index.ts create mode 100644 packages/stack/tests/bcmr/bcmr-composite.test.ts diff --git a/packages/stack/src/bcmr/base-bcmr.ts b/packages/stack/src/bcmr/base-bcmr.ts new file mode 100644 index 0000000..6299abf --- /dev/null +++ b/packages/stack/src/bcmr/base-bcmr.ts @@ -0,0 +1,42 @@ +import { MetadataRegistry } from './bcmr-helpers.js'; + +import { findAllKeyOcurrences } from '../utils/index.js'; + +export abstract class BaseBCMR { + /** + * Retrieves the metadata for a given token categoryId. + * + * @param {string} categoryId The token category id + * + * @throws {Error} If the fetching fails. + * @returns {Promise} A promise that resolves with the Metadata Registry. + */ + abstract getTokenData(categoryId: string): Promise; + + /** + * Return a list of all the ocurrences for a given token and field. + * + * @param {string} categoryId The token category id + * @param {string} field The field to lookup to. + * + * @throws {Error} If the fetching fails. + * @throws {Error} If there is not entry for the given 'categoryId' registered on the indexer. + * @returns {Promise>} A promise that resolves with the list of values for the given field. + */ + async searchField( + categoryId: string, + field: string, + ): Promise> { + try { + const data = await this.getTokenData(categoryId); + + const occurrencesList = findAllKeyOcurrences(data, field); + + return occurrencesList; + } catch (error: unknown) { + throw new Error( + `Error trying to get token metadata for ${categoryId}. Detailed error: ${error}`, + ); + } + } +} diff --git a/packages/stack/src/bcmr/bcmr-composite.ts b/packages/stack/src/bcmr/bcmr-composite.ts new file mode 100644 index 0000000..baf2d82 --- /dev/null +++ b/packages/stack/src/bcmr/bcmr-composite.ts @@ -0,0 +1,25 @@ +import { BaseBCMR } from './base-bcmr.js'; + +import { BCMRHttp } from './bcmr-http.js'; + +export class BCMRComposite extends BaseBCMR { + constructor(public readonly bridges: Array) { + super(); + } + + async getTokenData(categoryId: string) { + const getTokenDataPromises = this.bridges.map((bridge) => + bridge.getTokenData(categoryId), + ); + + try { + const data = await Promise.any(getTokenDataPromises); + + return data; + } catch (error: unknown) { + throw new Error( + `Unable to get token metadata from any of the BCMR indexers. Detailed error: ${error}`, + ); + } + } +} diff --git a/packages/stack/src/bcmr/bcmr-helpers.ts b/packages/stack/src/bcmr/bcmr-helpers.ts new file mode 100644 index 0000000..08fc6f1 --- /dev/null +++ b/packages/stack/src/bcmr/bcmr-helpers.ts @@ -0,0 +1,24 @@ +import { z } from 'zod/v4'; + +export const MetadataRegistrySchema = z + .object({ + name: z.string(), + description: z.string().optional(), + uris: z + .object({ + // Since 'icon' and 'web' are strongly recommended uri identifiers(https://github.com/bitjson/chip-bcmr?tab=readme-ov-file#recommended-uri-identifiers) + // We require it as non optional. + icon: z.url(), + web: z.url(), + }) + // Allows any other keys with any values. + .catchall(z.unknown()), + token: z.object({ + symbol: z.string(), + category: z.string(), + decimals: z.number().optional(), + }), + }) + .catchall(z.unknown()); + +export type MetadataRegistry = z.infer; diff --git a/packages/stack/src/bcmr/bcmr-http.ts b/packages/stack/src/bcmr/bcmr-http.ts new file mode 100644 index 0000000..451e7d3 --- /dev/null +++ b/packages/stack/src/bcmr/bcmr-http.ts @@ -0,0 +1,130 @@ +import { BaseBCMR } from './base-bcmr.js'; + +import { MetadataRegistry, MetadataRegistrySchema } from './bcmr-helpers.js'; + +import { type BaseStore, StoreInMemory } from '../storage/index.js'; + +export type BCMRHttpOpts = { + store?: BaseStore; + indexerUri: string; + expireCacheAfterSeconds?: number; + endpointEndsWithDotJson?: boolean; +}; + +type CachedMetadataRegistry = { + timestampInSeconds: number; + metadataRegistry: MetadataRegistry; +}; + +// The proposal is to expire cache after 7 days. +// https://github.com/bitjson/chip-bcmr?tab=readme-ov-file#well-known-uri +const DEFAULT_EXPIRE_CACHE_AFTER_SECONDS = 60 * 60 * 24 * 7; + +// Define the options for our POST request. +const fetchOptions = { + method: 'GET', + cache: 'no-cache' as const, + headers: { 'Content-Type': 'application/json' }, +}; + +export class BCMRHttp extends BaseBCMR { + // Options + private store: BaseStore; + private cacheExpirationInSeconds: number; + private indexerUri: string; + private endpointEndsWithDotJson: boolean; + + constructor(opts: BCMRHttpOpts) { + // Check that given indexer url is valid. + BCMRHttp.isUrl(opts.indexerUri); + + super(); + + // Set the store, or use the memory store by default. + this.store = opts.store ?? new StoreInMemory(); + + this.cacheExpirationInSeconds = + opts.expireCacheAfterSeconds ?? DEFAULT_EXPIRE_CACHE_AFTER_SECONDS; + + const endChar = opts.indexerUri.slice(-1) === '/' ? '' : '/'; + this.indexerUri = `${opts.indexerUri}${endChar}`; + + this.endpointEndsWithDotJson = Boolean(opts.endpointEndsWithDotJson); + } + + async getTokenData(categoryId: string) { + // Define the cache key. + const cacheKey = `tokenMetadata.${categoryId}`; + + const cachedTokenMetadata = await this.store.get(cacheKey); + + if (cachedTokenMetadata) { + // Check if cache is not expired + if ( + Date.now() < + (cachedTokenMetadata.timestampInSeconds + + this.cacheExpirationInSeconds) * + 1000 + ) + return cachedTokenMetadata.metadataRegistry; + + // Otherwise if expired... + await this.store.delete(cacheKey); + } + + try { + const metadataRegistry = await this.fetchMetadata(categoryId); + + const cachedMetadataRegistry: CachedMetadataRegistry = { + timestampInSeconds: Date.now() / 1000, + metadataRegistry, + }; + + // Cache token metadata. + await this.store.set(cacheKey, cachedMetadataRegistry); + + return metadataRegistry; + } catch (error: unknown) { + throw new Error( + `Error trying to fetch token metadata. Detailed error: ${error}`, + ); + } + } + + /** + * Try to fetch the metadata of a given token 'categoryId' using the given indexer. + * + * @throws {Error} If the fetch fails. + * @throws {Error} If the response fails to be parsed. + */ + private async fetchMetadata(categoryId: string): Promise { + // Prepare the uri to fetch the token metadata. + const uri = `${this.indexerUri}${categoryId}${this.endpointEndsWithDotJson ? '.json' : ''}`; + + const response = await fetch(uri, fetchOptions); + + // If the request was not successful... + if (!response.ok) + throw new Error( + `Error trying to fetch token (${categoryId}) metadata at ${uri}`, + ); + + // Validate the payload body. + try{ + const metadata = MetadataRegistrySchema.parse(await response.json()); + + return metadata; + } catch(error: unknown) { + throw new Error(`Unable to parse the metadata registry format. Detailed error: ${error}`); + } + } + + static isUrl(value: string): boolean { + // Validate as URL using try-catch + try { + return Boolean(new URL(value)); + } catch (error: unknown) { + throw new Error(`Invalid URL. Detailed error: ${error}`); + } + } +} diff --git a/packages/stack/src/bcmr/index.ts b/packages/stack/src/bcmr/index.ts new file mode 100644 index 0000000..be5d9ee --- /dev/null +++ b/packages/stack/src/bcmr/index.ts @@ -0,0 +1,3 @@ +// NOTE: Do not export specific providers in this file. +// Do this in the parent index.ts file instead (otherwise, we mingle interfaces and providers, which can create circular dependencies).. +export * from './base-bcmr.js'; diff --git a/packages/stack/src/blockchain/blockchain-chaingraph.ts b/packages/stack/src/blockchain/blockchain-chaingraph.ts index f2bd81d..32802cb 100644 --- a/packages/stack/src/blockchain/blockchain-chaingraph.ts +++ b/packages/stack/src/blockchain/blockchain-chaingraph.ts @@ -79,11 +79,9 @@ export class BlockchainChaingraph extends EventEmitter implements Omit< - BaseBlockchain< - ChaingraphLockscriptNotification - // TODO: Need to make 'BaseBlockchain' able to notify that is able to return this? - // CustomSubscriptionNotification - >, + // TODO: Need to make 'BaseBlockchain' able to notify that is able to return this? + // CustomSubscriptionNotification + BaseBlockchain, 'fetchAddressHistory' > { @@ -101,10 +99,8 @@ export class BlockchainChaingraph > = new Map(); // Transaction Subscription State. - transactionSubscriptions: Map< - string, - Set> - > = new Map(); + transactionSubscriptions: Map>> = + new Map(); // Custom Subscriptions. customSubscriptions: Map< diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index b4e639c..058ffb6 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -2,6 +2,7 @@ export * from '@xocash/primitives'; // Export all of our stack-specific modules. +export * from './bcmr/index.js'; export * from './blockchain/index.js'; export * from './protocols/index.js'; export * from './rates/index.js'; @@ -12,6 +13,8 @@ export * from './wallet/index.js'; // Export providers that work across both Browser and NodeJS. // NOTE: For browser-only providers, export these from index.browser.ts. // For node-only providers, export these from index.node.ts. +export * from './bcmr/bcmr-composite.js'; +export * from './bcmr/bcmr-http.js'; export * from './blockchain/blockchain-electrum.js'; export * from './blockchain/blockchain-chaingraph.js'; export * from './storage/store-remote-http.js'; diff --git a/packages/stack/src/storage/base-store.ts b/packages/stack/src/storage/base-store.ts index bcb0882..152710b 100644 --- a/packages/stack/src/storage/base-store.ts +++ b/packages/stack/src/storage/base-store.ts @@ -79,7 +79,7 @@ export type StoreValueProxy = { * } * ``` */ -export abstract class BaseStore extends EventEmitter { +export abstract class BaseStore extends EventEmitter { /** * Returns an array of all keys in the store. */ @@ -91,7 +91,7 @@ export abstract class BaseStore extends EventEmitter { * @template T The type of the value to retrieve * @param key The key to lookup */ - abstract get(key: string): Promise; + abstract get(key: string): Promise; /** * Stores a value in the store with the specified key. @@ -100,7 +100,7 @@ export abstract class BaseStore extends EventEmitter { * @param key The key under which to store the value * @param value The value to store */ - abstract set(key: string, value: T): Promise; + abstract set(key: string, value: T): Promise; /** * Removes a value from the store by its key. @@ -114,7 +114,7 @@ export abstract class BaseStore extends EventEmitter { * * @template T The type of the values in the store */ - async all(): Promise<{ [key: string]: T }> { + async all(): Promise<{ [key: string]: T }> { const keys = await this.keys(); const values = await Promise.all(keys.map((key) => this.get(key))); @@ -154,7 +154,7 @@ export abstract class BaseStore extends EventEmitter { * @template T The type of the values to retrieve * @param keys Array of keys to retrieve */ - async batchGet(keys: Array): Promise> { + async batchGet(keys: Array): Promise> { const results = await Promise.all( keys.map(async (key) => { const value = await this.get(key); @@ -174,7 +174,7 @@ export abstract class BaseStore extends EventEmitter { * @template T The type of the values to store * @param items Map of key-value pairs to store */ - async batchSet(items: Map): Promise { + async batchSet(items: Map): Promise { await Promise.all( Array.from(items).map(([key, value]) => { return this.set(key, value); @@ -197,7 +197,7 @@ export abstract class BaseStore extends EventEmitter { * @template T The type of the value to retrieve * @param key The key to lookup */ - async getOrFail(key: string): Promise { + async getOrFail(key: string): Promise { // Attempt to get the value from the store. const value = await this.get(key); @@ -217,7 +217,10 @@ export abstract class BaseStore extends EventEmitter { * @param key The key to lookup * @param setter Function to generate the value if it doesn't exist */ - async getOrSet(key: string, setter: () => T | Promise): Promise { + async getOrSet( + key: string, + setter: () => T | Promise, + ): Promise { // Attempt to get the value from the store. let value = await this.get(key); @@ -246,7 +249,7 @@ export abstract class BaseStore extends EventEmitter { * await numValue.set(42); // sets the stored value * ``` */ - proxy(key: string): StoreValueProxy { + proxy(key: string): StoreValueProxy { const self = this; return { diff --git a/packages/stack/src/utils/misc.ts b/packages/stack/src/utils/misc.ts index 7b11795..c4963f1 100644 --- a/packages/stack/src/utils/misc.ts +++ b/packages/stack/src/utils/misc.ts @@ -39,3 +39,75 @@ export function hasDuplicateParams(url: string | URL): boolean { return false; } + +/** + * Check if the given object is a Record. + * + * @param object The variable to check. + * @returns True if the object is a record. + */ +export function isRecord( + value: unknown, +): value is Record { + if (typeof value !== 'object' || value === null) return false; + + // Check if its a plain object (not a class instance, array, etc.) + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +} + +/** + * Find all the ocurrences of a given key in a plain JS object. + * NOTE: This function is intended to search keys in JSON objects + * and it will not search the key walking through collection object structures(Set, Map, etc.) + * NOTE: This function is intended to be used to traverse not too nested JSON objects. + * + * @param object The plain JS Object to traverse. + * @param key The Key to search. + * + * @throws If object can't be serializated as JSON. + * @throws If object is more than 100 levels nested. + * @returns A list of values for the given key. + */ +export function findAllKeyOcurrences( + object: Record, + key: string, +): Array { + // Check object serialization as JSON to avoid circular reference, BigInt values or other object structures(Set, Map, Date, etc..) + JSON.stringify(object); + + // Under 100 the resource allocation is excessively fine for any JS environment or hardware. + // NOTE: If for some reason this needs to be changed be sure to add proper test cases to measure performance. + // Currently(11/09/2025) the JS stack size can handle roughly 10000 recursive calls on browsers and NodeJS. + const allowedNestedLevels = 100; + + const keyValues: Array = []; + + function traverse(currentObj: Record, depth: number = 0) { + // Stop if depth exceeds the amount of 'allowedNestedLevels'. + if (depth > allowedNestedLevels) + throw new Error(`Maximum nesting level of ${allowedNestedLevels} reached.`); + + // Stop when find a non object value. + if (typeof currentObj !== 'object' || currentObj === null) return; + + if (Array.isArray(currentObj)) { + currentObj.forEach((item) => traverse(item, depth + 1)); + return; + } + + // Check if current object has the key property we are looking + if (key in currentObj) { + keyValues.push(currentObj[key]); + } + + // Recursively traverse all properties + Object.values(currentObj).forEach((value) => { + if (isRecord(value)) traverse(value, depth + 1); + }); + } + + traverse(object); + + return keyValues; +} diff --git a/packages/stack/tests/bcmr/bcmr-composite.test.ts b/packages/stack/tests/bcmr/bcmr-composite.test.ts new file mode 100644 index 0000000..03628c7 --- /dev/null +++ b/packages/stack/tests/bcmr/bcmr-composite.test.ts @@ -0,0 +1,46 @@ +import { test, expect, describe } from 'vitest'; + +import { BCMRComposite } from '../../src/bcmr/bcmr-composite.js'; +import { BCMRHttp } from '../../src/index.js'; + +// musd-v1 +const categoryId = + 'b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366'; +const musdTokenField = { + x: 'https://x.com/MoriaMoney/', + web: 'https://moria.money', + icon: 'https://meta.moria.money/m1.png', + telegram: 'https://t.me/moriamoney', +}; + +describe('BCMRComposite', () => { + const bcmrComposite = new BCMRComposite([ + new BCMRHttp({ indexerUri: 'https://bcmr.paytaca.com/api/tokens/' }), + new BCMRHttp({ + indexerUri: 'https://bcmr.flowee.cash/', + endpointEndsWithDotJson: true, + }), + ]); + + test('should return token metadata', async () => { + const metadata = await bcmrComposite.getTokenData(categoryId); + + expect(metadata).toBeDefined(); + expect(metadata?.token.symbol).toBeDefined(); + expect(metadata?.token.symbol).toBe('MUSD'); + }); + + test('metadata should have a uris field that matches', async () => { + const matches = await bcmrComposite.searchField(categoryId, 'uris'); + + expect(Array.isArray(matches)).toBeTruthy(); + expect((matches as Array).length).toBeGreaterThan(0); + expect((matches as Array)[0]).toMatchObject(musdTokenField); + }); + + test('should fail if cantegoryId is not a registered entry', async () => { + const invalidCategoryId = 'asd123'; + + await expect(bcmrComposite.getTokenData(invalidCategoryId)).rejects.toThrow(); + }); +}); diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index 24b09b6..c9fdde5 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "./dist", "rootDir": "./src", "baseUrl": ".", - "lib": ["dom"] + "lib": ["dom", "es2021"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]