Merge branch '41-bcmr-adapter-create-basebcmr-and-http-implementation' into 'development'

Resolve "BCMR Adapter: Create BaseBCMR and HTTP implementation"

Closes #41

See merge request GeneralProtocols/xo/stack!124
This commit is contained in:
Emergent Reasons
2025-09-18 13:09:11 +00:00
11 changed files with 363 additions and 19 deletions
+42
View File
@@ -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<MetadataRegistry>} A promise that resolves with the Metadata Registry.
*/
abstract getTokenData(categoryId: string): Promise<MetadataRegistry>;
/**
* 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<Array<unknown>>} A promise that resolves with the list of values for the given field.
*/
async searchField(
categoryId: string,
field: string,
): Promise<Array<unknown>> {
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}`,
);
}
}
}
+25
View File
@@ -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<BCMRHttp>) {
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}`,
);
}
}
}
+24
View File
@@ -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<typeof MetadataRegistrySchema>;
+130
View File
@@ -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<CachedMetadataRegistry>;
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<CachedMetadataRegistry>;
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<MetadataRegistry> {
// 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}`);
}
}
}
+3
View File
@@ -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';
@@ -79,11 +79,9 @@ export class BlockchainChaingraph
extends EventEmitter<BlockchainEvents>
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<ChaingraphLockscriptNotification>,
'fetchAddressHistory'
>
{
@@ -101,10 +99,8 @@ export class BlockchainChaingraph
> = new Map();
// Transaction Subscription State.
transactionSubscriptions: Map<
string,
Set<BlockchainCallback<undefined>>
> = new Map();
transactionSubscriptions: Map<string, Set<BlockchainCallback<undefined>>> =
new Map();
// Custom Subscriptions.
customSubscriptions: Map<
+3
View File
@@ -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';
+12 -9
View File
@@ -79,7 +79,7 @@ export type StoreValueProxy<T> = {
* }
* ```
*/
export abstract class BaseStore extends EventEmitter<StoreEvents> {
export abstract class BaseStore<K = unknown> extends EventEmitter<StoreEvents> {
/**
* Returns an array of all keys in the store.
*/
@@ -91,7 +91,7 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
* @template T The type of the value to retrieve
* @param key The key to lookup
*/
abstract get<T>(key: string): Promise<T | undefined>;
abstract get<T extends K>(key: string): Promise<T | undefined>;
/**
* Stores a value in the store with the specified key.
@@ -100,7 +100,7 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
* @param key The key under which to store the value
* @param value The value to store
*/
abstract set<T>(key: string, value: T): Promise<void>;
abstract set<T extends K>(key: string, value: T): Promise<void>;
/**
* Removes a value from the store by its key.
@@ -114,7 +114,7 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
*
* @template T The type of the values in the store
*/
async all<T>(): Promise<{ [key: string]: T }> {
async all<T extends K>(): Promise<{ [key: string]: T }> {
const keys = await this.keys();
const values = await Promise.all(keys.map((key) => this.get<T>(key)));
@@ -154,7 +154,7 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
* @template T The type of the values to retrieve
* @param keys Array of keys to retrieve
*/
async batchGet<T>(keys: Array<string>): Promise<Map<string, T>> {
async batchGet<T extends K>(keys: Array<string>): Promise<Map<string, T>> {
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<StoreEvents> {
* @template T The type of the values to store
* @param items Map of key-value pairs to store
*/
async batchSet<T>(items: Map<string, T>): Promise<void> {
async batchSet<T extends K>(items: Map<string, T>): Promise<void> {
await Promise.all(
Array.from(items).map(([key, value]) => {
return this.set(key, value);
@@ -197,7 +197,7 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
* @template T The type of the value to retrieve
* @param key The key to lookup
*/
async getOrFail<T>(key: string): Promise<T> {
async getOrFail<T extends K>(key: string): Promise<T> {
// Attempt to get the value from the store.
const value = await this.get<T>(key);
@@ -217,7 +217,10 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
* @param key The key to lookup
* @param setter Function to generate the value if it doesn't exist
*/
async getOrSet<T>(key: string, setter: () => T | Promise<T>): Promise<T> {
async getOrSet<T extends K>(
key: string,
setter: () => T | Promise<T>,
): Promise<T> {
// Attempt to get the value from the store.
let value = await this.get<T>(key);
@@ -246,7 +249,7 @@ export abstract class BaseStore extends EventEmitter<StoreEvents> {
* await numValue.set(42); // sets the stored value
* ```
*/
proxy<T>(key: string): StoreValueProxy<T> {
proxy<T extends K>(key: string): StoreValueProxy<T> {
const self = this;
return {
+72
View File
@@ -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<string | number | symbol, unknown> {
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<string | number | symbol, unknown>,
key: string,
): Array<unknown> {
// 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<unknown> = [];
function traverse(currentObj: Record<string | number | symbol, unknown>, 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;
}
@@ -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<unknown>).length).toBeGreaterThan(0);
expect((matches as Array<unknown>)[0]).toMatchObject(musdTokenField);
});
test('should fail if cantegoryId is not a registered entry', async () => {
const invalidCategoryId = 'asd123';
await expect(bcmrComposite.getTokenData(invalidCategoryId)).rejects.toThrow();
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"lib": ["dom"]
"lib": ["dom", "es2021"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]