// Copyright Abridged, Inc. 2021,2024. All Rights Reserved. // Node module: @collabland/common // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT import {BigNumber} from '@ethersproject/bignumber'; import HttpErrors from 'http-errors'; import jsonata, {Focus} from 'jsonata'; import lodash from 'lodash'; import {makeRe, type MinimatchOptions} from 'minimatch'; import {AnyError, AnyType} from '../types.js'; import {BN, BNLike, toBigNumber} from './bignumber.js'; import {loggers} from './debug-factory.js'; import {inspectJson} from './debug.js'; import {tokenize} from './tokenize.js'; export {MinimatchOptions as GlobOptions} from 'minimatch'; const {debug, error} = loggers('collabland:jsonata:info'); export type JsonEvaluator = (( input: AnyType, bindings?: Record, ) => Promise) & { query: string; variables?: Record; }; export type JsonQueryFunction = { // eslint-disable-next-line @typescript-eslint/no-explicit-any implementation: (this: Focus, ...args: any[]) => any; signature?: string; }; /** * Check the list contains given items * @param list - An array of items * @param items - One or more items to be checked * @returns */ function includesItemsInArray(list: unknown[], items: unknown[] | unknown) { debug('$includes(%O, %O)', list, items); let result = false; if (Array.isArray(items)) { result = items.every(v => list.some(val => lodash.isEqual(val, v))); } else { result = list.some(v => lodash.isEqual(v, items)); } debug('$includes() returns %s', result); return result; } /** * Check the list contains given items * @param object - An object of items keyed by id or name * @param items - One or more items to be checked * @returns */ function includesItemsInObject( obj: Record, items: unknown[] | unknown, ) { debug('$includes(%O, %O)', obj, items); const result = Object.entries(obj).some(([k, v]) => { const list = Array.isArray(v) ? v : [v]; return includesItemsInArray(list, items); }); debug('$includes() returns %s', result); return result; } function includesItems( parent: Record | unknown[] | unknown, items: unknown[] | unknown, ) { parent = parent ?? []; if (typeof parent !== 'object') { parent = [parent]; } if (Array.isArray(parent)) { return includesItemsInArray(parent, items); } if (typeof parent === 'object') { return includesItemsInObject(parent as Record, items); } throw Error('The first argument must be an object or array'); } /** * `$includes` checks the first argument includes the child items. The following * structures are supported: * * - $includes(['a', 'b', 'c'], 'a') * - $includes(['a', 'b', 'c'], ['a', 'c']) * - $includes({x: ['a', 'b', 'c']}, ['a', 'c']) * - $includes({x: ['a', 'b'], y: ['a', 'c']}, ['a', 'c']) * - $includes({x: ['a', 'b', 'c']}, 'b') * - $includes({x: 'a', y: 'b'}, 'b') */ const includes: JsonQueryFunction = { implementation: includesItems, signature: '<(asnbol)(asnblo):b>', }; function getIntersection(list: unknown[], items: unknown[]) { debug('$intersect(%O, %O)', list, items); const result = list.filter(v => items.some(i => lodash.isEqual(v, i))); debug('$intersect() returns %s', result); return result; } const intersect: JsonQueryFunction = { implementation: getIntersection, signature: '<(a)(a):a>', }; function toBN(val: BNLike) { val = val ?? '0'; return toBigNumber(val)!; } function _add(a: BNLike, b: BNLike) { debug('$add(%s,%s)', a, b); return toBN(a).add(toBN(b)); } function _sub(a: BNLike, b: BNLike) { debug('$sub(%s,%s)', a, b); return toBN(a).sub(toBN(b)); } function _addAll(items: BNLike[] | BNLike) { debug('$addAll(%O)', items); const vals = Array.isArray(items) ? items : [items]; let result = BN.from(0); vals.forEach(v => { result = result.add(toBN(v)); }); debug('$addAll() returns %s', result); return result; } const add: JsonQueryFunction = { implementation: _add, signature: '<(snlo)(snlo):o>', }; const addAll: JsonQueryFunction = { implementation: _addAll, signature: '<(asnbol):o>', }; const sub: JsonQueryFunction = { implementation: _sub, signature: '<(snlo)(snlo):o>', }; function compare(a: BNLike, b: BNLike) { debug('$compare(%O,%O)', a, b); if (a === b) return 0; if (a == null || b == null) return undefined; const x = toBigNumber(a); const y = toBigNumber(b); let result: number; if (x.gt(y)) { result = 1; } else if (x.lt(y)) { result = -1; } else { result = 0; } debug('Result: %s', result); return result; } const compareTo: JsonQueryFunction = { implementation: (a: BNLike, b: BNLike) => compare(a, b), signature: '<(snblo)(snblo):(bl)>', }; const equal = (a: BNLike, b: BNLike) => compare(a, b) === 0; const eq: JsonQueryFunction = { implementation: equal, signature: '<(snblo)(snblo):(bl)>', }; const notEqual = (a: BNLike, b: BNLike) => { const result = compare(a, b); return result == null || result !== 0; }; const ne: JsonQueryFunction = { implementation: notEqual, signature: '<(snblo)(snblo):(bl)>', }; const greaterThan = (a: BNLike, b: BNLike) => compare(a, b) === 1; const gt: JsonQueryFunction = { implementation: greaterThan, signature: '<(snblo)(snblo):(bl)>', }; const greaterThanOrEqual = (a: BNLike, b: BNLike) => { const result = compare(a, b); return result != null && result !== -1; }; const gte: JsonQueryFunction = { implementation: greaterThanOrEqual, signature: '<(snblo)(snblo):(bl)>', }; const lessThan = (a: BNLike, b: BNLike) => compare(a, b) === -1; const lt: JsonQueryFunction = { implementation: lessThan, signature: '<(snblo)(snblo):(bl)>', }; const lessThanOrEqual = (a: BNLike, b: BNLike) => { const result = compare(a, b); return result != null && result !== 1; }; const lte: JsonQueryFunction = { implementation: lessThanOrEqual, signature: '<(snblo)(snblo):(bl)>', }; function _between(value: BNLike, min: BNLike, max: BNLike) { // The value is default to 0 if it does not exist value = value ?? 0; debug('$between(%s, %s, %s)', value, min, max); const result = (min == null || greaterThanOrEqual(value, min)) && (max == null || lessThanOrEqual(value, max)); debug('Result: %s', result); return result; } const between: JsonQueryFunction = { implementation: (value: BNLike, min: BNLike, max: BNLike) => { return _between(value, min, max); }, signature: '<(snblo)(snblo)(snblo):b>', }; const toString: JsonQueryFunction = { implementation: (value: unknown) => { const str = String(value); if (str === '[object Object]') { return JSON.stringify(value); } return str; }, signature: '<(snblo):s>', }; const inRanges: JsonQueryFunction = { implementation: (value: unknown, ranges: string) => { return isInRanges(value, ranges); }, signature: '<(snlo)s:b>', }; /** * Trait type/value pair */ export type Trait = {trait_type: string; value: AnyType}; /** * Filter to match a list of trait type/value pairs against an individual token * using `and` or `or` condition */ export type TraitFilterPerToken = { /** * Minimal count of unique tokens that match the traits */ minCount?: BNLike; /** * Maximal count of unique tokens that match the traits */ maxCount?: BNLike; /** * Use `and` or `or` logic. * - and: The token needs to have all trait type/value pairs * - or: The token needs to have at least one of the trait type/value pairs */ condition?: 'and' | 'or'; /** * A list of trait type/value pairs to match individual tokens */ traits: Trait[]; }; /** * Trait based filter for all tokens owned by an account */ export type TraitFilterForAllTokens = { /** * Asset uri */ asset?: string; /** * Token id(s) separated by `,`. It can include ranges and wildcards. */ tokenId?: string; /** * Use `and` or `or` logic * - and: All filters need to be satisfied by tokens owned by an account * - or: At least one of the filters need to be satisfied by tokens owned by * an account */ condition?: 'and' | 'or'; /** * A list of filters at per token level to qualify individual tokens */ filters: TraitFilterPerToken[]; }; /** * Evaluate the condition against a trait * @param condition - Trait condition * @param trait - Trait * @returns */ async function evaluateTrait(condition: Trait, trait: Trait) { const conditionType = condition.trait_type.trim().toLowerCase(); const traitType = trait.trait_type?.trim().toLowerCase(); if (conditionType !== traitType) return false; if (typeof condition.value === 'string' && condition.value.startsWith('$')) { debug('Evaluating %O as a query against %O', condition, trait); // Treat it as a JSONata query expression, such as $ >= 10 const result = await jsonQuery(condition.value)(trait.value); return !!result; } const conditionValue = condition.value; const traitValue = trait.value; // eslint-disable-next-line eqeqeq if (conditionValue == traitValue) return true; if (typeof conditionValue === 'string') { const traitValueStr = traitValue?.toString().trim().toLowerCase(); return conditionValue.trim().toLowerCase() === traitValueStr; } return false; } async function checkConditions( condition: 'and' | 'or' | undefined, traits: T[], fn: (condition: T) => Promise, ) { if (condition === 'or') { let matched = false; for (const t of traits) { matched = await fn(t); if (matched) return true; } return matched; } else { let matched = false; for (const t of traits) { matched = await fn(t); if (!matched) return false; } return matched; } } async function matchesTraitsPerToken( value: AnyType, filter: TraitFilterPerToken, ) { let attrs: Trait[] = value?.metadata?.attributes ?? []; if (!Array.isArray(attrs) && typeof attrs === 'object') { // The attributes can be an object attrs = Object.entries(attrs).map(e => ({ trait_type: e[0], value: e[1], })); } const fn = async (condition: Trait) => { let matched = false; for (const a of attrs) { const result = await evaluateTrait(condition, a); if (result) { matched = true; break; } } debug('Matching trait %O against %O: %s', condition, attrs, matched); return matched; }; return checkConditions(filter.condition, filter.traits, fn); } const ownsTraitsPerToken: JsonQueryFunction = { implementation: (value: AnyType, filter: TraitFilterPerToken) => { return matchesTraitsPerToken(value, filter); }, signature: '<(j)(o):b>', }; const ownsTraits: JsonQueryFunction = { implementation: async ( balances: | {tokens: AnyType[]; asset: string} | {tokens: AnyType[]; asset: string}[] | undefined | null, filter: TraitFilterForAllTokens, ) => { debug('ownsTraits(%O, %O)', balances, filter); if (balances == null) return false; if (!Array.isArray(balances)) { balances = [balances]; } let tokenId = filter.tokenId; if (tokenId == null) { // Check token ids from the asset uri const parts = filter.asset?.split('/'); // FIXME(rfeng): solana uses `tokenId` part for other purposes if (!filter.asset?.startsWith('solana:') && parts?.length === 3) { // evm:1/ERC721:/ tokenId = parts[2]; } } const result = balances.filter( b => filter.asset == null || filter.asset.toLowerCase() === b?.asset?.toLowerCase(), ); const check = async (b: {tokens: AnyType[]; asset: string}) => { const tokens = b?.tokens?.filter(t => { return tokenId == null || isInRanges(t.id, tokenId); }) ?? []; if (filter.filters == null || filter.filters.length === 0) { return true; } const fn = async (traits: TraitFilterPerToken) => { let total = BigNumber.from(0); for (const t of tokens) { const matched = await matchesTraitsPerToken(t, traits); if (matched) { total = _add(total, t.balance ?? '1'); } } return _between(total, traits.minCount ?? 1, traits.maxCount); }; return checkConditions(filter.condition, filter.filters, fn); }; let matched = false; for (const b of result) { matched = await check(b); if (matched) return true; } return matched; }, signature: '<(lao)(o):b>', }; /** * Quantity based balance filter */ export type BalanceFilter = { /** * Asset uri */ asset?: string; /** * Minimal count */ minCount?: BNLike; /** * Maximal count */ maxCount?: BNLike; /** * Token id patterns */ tokenId?: string; }; const ownsBalance: JsonQueryFunction = { implementation: ( balances: | {tokens?: AnyType[]; asset: string; balance: AnyType} | {tokens?: AnyType[]; asset: string; balance: AnyType}[] | undefined | null, filter: BalanceFilter, ) => { debug('ownsBalance(%O, %O)', balances, filter); const {asset, minCount, maxCount, tokenId} = filter; if (balances == null) return BN.from(0); if (!Array.isArray(balances)) { balances = [balances]; } const assets = balances.filter( b => asset == null || asset.toLowerCase() === b?.asset?.toLowerCase(), ); debug('Assets: %O', assets); const result = assets .map(a => { if (a.tokens == null || a.tokens.length === 0) { return a.balance ?? '0'; } return a.tokens .filter(t => tokenId == null || isInRanges(t.id, tokenId)) .map(t => t.balance ?? '1'); }) .flat(); const total = _addAll(result); debug('Total balance: %s', total); return _between(total, minCount, maxCount); }, signature: '<(lao)(lo):o>', }; /** * Global jsonata functions */ export const globalJsonQueryFunctions: Record = { between, includes, intersect, compare: compareTo, eq, ne, gt, gte, lt, lte, add, sub, addAll, toString, // This is different from $string() which uses JSON.stringify inRanges, ownsTraits, ownsTraitsPerToken, ownsBalance, }; /** * Create a jsonata query expression * @param query - JSONata query string * @param bindings - Optional bindings for variables * @returns */ export function jsonQuery( query: string, bindings: Record = {}, ): JsonEvaluator { debug('Building JSON query: %s, %O', query, bindings); const exp = jsonata(query); for (const b in bindings) { if (BUILT_IN_FUNCTIONS.has(b)) { throw new HttpErrors.BadRequest( `The built-in function ${b} cannot be overridden.`, ); } exp.assign(b, bindings[b]); } /** * Register $between(value, min, max) */ for (const f in globalJsonQueryFunctions) { exp.registerFunction( f, globalJsonQueryFunctions[f].implementation, globalJsonQueryFunctions[f].signature, ); } const evaluator: JsonEvaluator = async ( input: AnyType, vars?: Record, ) => { if (debug.enabled) { debug( 'JSONdata input: %O, bindings: %O, query: %s, variables: %O', input, bindings, query, vars, ); debug('JSON input: %s', inspectJson(input)); } try { const result = await exp.evaluate(input, vars); debug('JSONata output: %O', result); return result; } catch (err: AnyError) { debug( 'Fail to evaluate JSONata: %O, bindings: %O, query: %s, variables: %O: %O', input, bindings, query, vars, err, ); throw err; } }; evaluator.query = query; evaluator.variables = bindings; return evaluator; } const BUILT_IN_FUNCTIONS = new Set([ 'sum', 'count', 'max', 'min', 'average', 'string', 'substring', 'substringBefore', 'substringAfter', 'lowercase', 'uppercase', 'length', 'trim', 'pad', 'match', 'contains', 'replace', 'split', 'join', 'formatNumber', 'formatBase', 'formatInteger', 'parseInteger', 'number', 'floor', 'ceil', 'round', 'abs', 'sqrt', 'power', 'random', 'boolean', 'not', 'map', 'zip', 'filter', 'single', 'reduce', 'sift', 'keys', 'lookup', 'append', 'exists', 'spread', 'merge', 'reverse', 'each', 'error', 'assert', 'type', 'sort', 'shuffle', 'distinct', 'base64encode', 'base64decode', 'encodeUrlComponent', 'encodeUrl', 'decodeUrlComponent', 'decodeUrl', 'eval', 'toMillis', 'fromMillis', 'clone', // CollabLand extensions ...Object.keys(globalJsonQueryFunctions), ]); /** * Parse a string as an array of ranges * @param ids - A string that lists items and/or item ranges, such as `1`, * `1,2,5`, or `1-2,4,6-8` * * @example * - '1' -> ['1'] * - '1,2' -> ['1','2'] * - '2-3' -> [['2','3']] * - '2-3,5' -> [['2','3'], '5'] * - '*,5' -> ['*', '5'] * - '5??' -> ['5??'] * - '-5' -> [['','5']] * - '5-' -> [['5','']] * @returns */ export function parseRanges(ids: string): ([string, string] | string)[] { let parts: string[]; if (ids.match(/{[^{}]+}/)) { // The glob brace extension can contain `,` parts = tokenize(ids, /[\s]+/m); } else { parts = tokenize(ids); } return parts.map(id => { if (!isGlobPattern(id) && id.includes('-')) { // 2-100 inclusive const bounds = id.replace(/\s+/g, '').split(/-/); return [bounds[0], bounds[1]]; } else { return id; } }); } export function isInRanges(value: unknown, ranges: string) { debug('$inRanges(%s, %s)', value, ranges); if (value == null) return false; const idStr = String(value); let hexIdString = idStr; try { hexIdString = BN.from(idStr).toHexString(); } catch (err: AnyError) { // Ignore error } if (idStr === ranges || hexIdString === ranges) { debug('Result: %s', true); return true; } const ids = parseRanges(ranges); // Check if the token id falls into the ranges const result = ids.some(range => { if (typeof range === 'string') { if (idStr === range || hexIdString === range) return true; if (isGlobPattern(range)) { // Wildcard const regex = globToRegExp(range); const matched = regex === false ? false : idStr.match(regex) ?? hexIdString.match(regex); if (matched != null) return true; } return false; } // range[0] <= id && id <= range[1] try { const val = BN.from(idStr); return ( (range[0] === '' || val.gte(BN.from(range[0]))) && (range[1] === '' || val.lte(BN.from(range[1]))) ); } catch (err: AnyError) { debug('%s is not a number', idStr); return ( (range[0] === '' || idStr.localeCompare(range[0]) !== -1) && (range[1] === '' || idStr.localeCompare(range[1]) !== 1) ); } }); debug('Result: %s', result); return result; } export function isGlobPattern(str: string) { return /[\?\*\[\]\{\}\(\)\|]/.test(str); } /** * Convert a glob pattern to RegExp * @param pattern - Glob pattern * @param options - Options * @returns */ export function globToRegExp(pattern: string, options?: MinimatchOptions) { return makeRe(pattern, options); } /** * Convert a wildcard pattern to RegExp * @param pattern - A wildcard string with `*` and `?` as special characters. * - `*` matches zero or more characters except `.` and `:` * - `?` matches exactly one character except `.` and `:` */ export function wildcardToRegExp(pattern: string, excludes = '.:'): RegExp { // Escape reserved chars for RegExp: // `- \ ^ $ + . ( ) | { } [ ] :` let regexp = pattern.replace(/[\-\[\]\/\{\}\(\)\+\.\\\^\$\|\:]/g, '\\$&'); // Replace wildcard chars `*` and `?` if (!excludes) { // `*` matches zero or more characters // `?` matches one character regexp = regexp.replace(/\*/g, '.*').replace(/\?/g, '.'); } else { // `*` matches zero or more characters except `.` and `:` // `?` matches one character except `.` and `:` regexp = regexp .replace(/\*/g, `[^${excludes}]*`) .replace(/\?/g, `[^${excludes}]`); } return new RegExp(`^${regexp}$`); } /** * Parse token ids and ranges into a list of ids * @param ids - Token ids such as `1,3-4,100` * @returns */ export function expandTokenIds( ids: string, options: {reportErrors?: boolean} = {}, ) { const result: string[] = []; const {reportErrors} = options; const resolved = ids; if (resolved == null) return undefined; ids = resolved; const ranges = parseRanges(ids); for (const range of ranges) { if (typeof range === 'string') { if (isGlobPattern(range)) { error('Wildcard is not supported: %s', range); if (reportErrors) { throw new Error(`Wildcard is not supported: ${range}`); } return undefined; } result.push(range); } else { try { const from = BN.from(range[0]); const to = BN.from(range[1]); if (to.sub(from).gt(BN.from(10000))) { error('Token range exceeds 10000: [%s, %s]', from, to); return undefined; } let i = BN.from(from); while (i.lte(to)) { result.push(i.toString()); i = i.add(BN.from(1)); } } catch (err: AnyError) { error('Invalid token id range: %s', range); if (reportErrors) { throw new Error( `Non-numeric token id range is not supported: ${range[0]}-${range[1]}`, ); } return undefined; } } } return result; }