diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b810edb..65447620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ order; the entry is the editorial text of the version's GitHub Release. The 1.7 maintenance line continues in `CHANGELOG.md` on the `release/1.7` branch. This project follows semantic versioning. +## [2.6.0] + +Adds an effective mint filter and sort to the market listing endpoints, so a client can range over the mint of a bridged asset and of a plain asset with one parameter. + +### Upgrading + +- Image `ghcr.io/atomicassets/atomicassets-api:2.6.0`. The `2.6` and `latest` tags move to it. +- The release has no migration and needs no operator action. + +### Features + +- `/v0/sales`, `/v1/sales`, `/v2/sales`, `/v1/auctions`, `/v1/buyoffers` and `/v1/template_buyoffers` accept `min_effective_mint` and `max_effective_mint`, and `/v1/sales/templates` accepts the two filters. Each is an integer from 1 up to `Number.MAX_SAFE_INTEGER`, either one alone is valid, and a minimum above the maximum returns 400. (#234) +- The effective mint of an asset is its `original_mint` from `atomicassets_original_mints` when a link row with a mint number exists, else its template mint. An asset with neither is ignored. A listing matches when at least one of its assets has an effective mint and every such asset lies in the range, so a listing of an asset with no link is still found by its template mint. (#234) +- The listing endpoints above, except `/v1/sales/templates`, accept `sort=effective_mint`, which orders by the lowest effective mint of the listing. It returns only listings where an asset has an effective mint, with or without a bound, and the matching `_count` endpoint counts the same listings. (#234) +- A request that uses `min_effective_mint`, `max_effective_mint` or `sort=effective_mint` needs `collection_name` and returns 400 without it, because without that scope each listing needs its own lookup of the link table. The `template_mint` filters and sorts keep their queries. (#234) + ## [2.5.0] Serves the original mint of a bridged asset, with filters, a sort and per-card totals, from a `simpleassets` handler that numbers the source assets and links each bridged asset to its source. diff --git a/src/api/namespaces/atomicmarket/effective-mint-suite.ts b/src/api/namespaces/atomicmarket/effective-mint-suite.ts new file mode 100644 index 00000000..df14912d --- /dev/null +++ b/src/api/namespaces/atomicmarket/effective-mint-suite.ts @@ -0,0 +1,192 @@ +import {expect} from 'chai'; +import {ApiError} from '../../error'; +import {AtomicMarketTestClient} from './test'; + +/** One asset of a test listing: the template mint and the link row it may carry. */ +export type MintAssetSpec = { + template?: number | null, + // undefined: no link row. null: a link row without a mint number. + original?: number | null, +}; + +export type EffectiveMintSuiteOptions = { + client: AtomicMarketTestClient, + txit: any, + // listingTemplateMint is the template mint range the listing stores, as [n,n], for the + // handlers that read a stored column. The sales filter table derives it from the assets. + makeListing: (collectionName: string, assets: MintAssetSpec[], listingTemplateMint?: number) => Promise, + query: (params: Record) => Promise, + count: (params: Record) => Promise, +}; + +export async function createMintAsset( + client: AtomicMarketTestClient, collectionName: string, spec: MintAssetSpec, + assetValues: Record = {} +): Promise { + const {asset_id} = await client.createAsset({ + collection_name: collectionName, + template_mint: spec.template ?? null, + ...assetValues, + }); + + if (spec.original !== undefined) { + await client.createOriginalMint({asset_id, original_mint: spec.original}); + } + + return asset_id; +} + +const SCOPE_MESSAGE = 'The effective mint filters and sort require collection_name'; +const RANGE_MESSAGE = 'Min effective mint can\'t be greater than max effective mint'; + +async function expectApiError(promise: Promise, code: number, message: string): Promise { + let err: any; + try { + await promise; + } catch (e) { + err = e; + } + + expect(err).to.be.instanceof(ApiError); + expect(err.code).to.equal(code); + expect(err.message).to.equal(message); +} + +export function defineEffectiveMintSuite(options: EffectiveMintSuiteOptions): void { + const {client, txit, makeListing, query, count} = options; + + async function setup(): Promise { + return (await client.createCollection()).collection_name; + } + + describe('effective mint filter and sort', () => { + txit('matches a linked single-asset listing by its original mint', async () => { + const c = await setup(); + const inRange = await makeListing(c, [{original: 5}]); + await makeListing(c, [{original: 50}]); + + expect(await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})) + .to.deep.equal([inRange]); + }); + + txit('matches an unlinked listing by its template mint', async () => { + const c = await setup(); + const inRange = await makeListing(c, [{template: 5}]); + await makeListing(c, [{template: 50}]); + + expect(await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})) + .to.deep.equal([inRange]); + }); + + txit('lets the original mint win over the template mint', async () => { + const c = await setup(); + const originalInRange = await makeListing(c, [{original: 5, template: 500}]); + await makeListing(c, [{original: 500, template: 5}]); + + expect(await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})) + .to.deep.equal([originalInRange]); + }); + + txit('falls back to the template mint for a link row without a mint', async () => { + const c = await setup(); + const inRange = await makeListing(c, [{original: null, template: 5}]); + await makeListing(c, [{original: null, template: 50}]); + + expect(await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})) + .to.deep.equal([inRange]); + }); + + txit('matches a multi-asset listing only when every asset with a mint is in range', async () => { + const c = await setup(); + const allIn = await makeListing(c, [{original: 5}, {template: 6}, {original: null, template: 4}]); + await makeListing(c, [{original: 5}, {template: 60}]); + await makeListing(c, [{original: 50}, {template: 5}]); + await makeListing(c, [{original: 5}, {template: 2}]); + // the asset without any mint is ignored + const withIgnored = await makeListing(c, [{original: 5}, {}]); + + expect((await query({collection_name: c, min_effective_mint: '4', max_effective_mint: '6'})).sort()) + .to.deep.equal([allIn, withIgnored].sort()); + }); + + txit('never matches a listing whose assets have no mint at all', async () => { + const c = await setup(); + await makeListing(c, [{}]); + await makeListing(c, [{}, {original: null}]); + + expect(await query({collection_name: c, min_effective_mint: '1'})).to.deep.equal([]); + expect(await query({collection_name: c, max_effective_mint: '1000'})).to.deep.equal([]); + expect(await query({collection_name: c, min_effective_mint: '1', max_effective_mint: '1000'})) + .to.deep.equal([]); + }); + + txit('accepts a minimum alone and a maximum alone', async () => { + const c = await setup(); + const low = await makeListing(c, [{original: 2}]); + const high = await makeListing(c, [{template: 20}]); + await makeListing(c, [{}]); + + expect(await query({collection_name: c, min_effective_mint: '10'})).to.deep.equal([high]); + expect(await query({collection_name: c, max_effective_mint: '10'})).to.deep.equal([low]); + }); + + txit('orders by the lowest effective mint and leaves out listings without one', async () => { + const c = await setup(); + const b = await makeListing(c, [{original: 30}, {template: 8}]); + const a = await makeListing(c, [{original: 3, template: 90}]); + const d = await makeListing(c, [{template: 20}]); + await makeListing(c, [{}]); + await makeListing(c, [{original: null}]); + + const asc = {collection_name: c, sort: 'effective_mint', order: 'asc'}; + const desc = {collection_name: c, sort: 'effective_mint', order: 'desc'}; + + expect(await query(asc)).to.deep.equal([a, b, d]); + expect(await query(desc)).to.deep.equal([d, b, a]); + expect(await count(asc)).to.equal(3); + expect(await query({...asc, limit: '1', page: '2'})).to.deep.equal([b]); + }); + + txit('applies a bound together with the sort', async () => { + const c = await setup(); + const a = await makeListing(c, [{original: 9}]); + const b = await makeListing(c, [{template: 5}]); + await makeListing(c, [{template: 500}]); + await makeListing(c, [{}]); + + const params = {collection_name: c, sort: 'effective_mint', order: 'asc', max_effective_mint: '10'}; + + expect(await query(params)).to.deep.equal([b, a]); + expect(await count(params)).to.equal(2); + }); + + txit('combines a template mint bound with an effective mint bound of the same value', async () => { + const c = await setup(); + const both = await makeListing(c, [{original: 5, template: 5}], 5); + // passes the template mint filter only: the original mint is out of range + await makeListing(c, [{original: 50, template: 5}], 5); + // passes the effective mint filter only: the stored template mint is out of range + await makeListing(c, [{original: 6, template: 50}], 50); + + expect(await query({ + collection_name: c, + min_template_mint: '5', min_effective_mint: '5', + max_template_mint: '6', max_effective_mint: '6', + })).to.deep.equal([both]); + expect(await query({collection_name: c, max_template_mint: '5', max_effective_mint: '5'})) + .to.deep.equal([both]); + }); + + txit('rejects the mint parameters without collection_name', async () => { + await expectApiError(query({min_effective_mint: '1'}), 400, SCOPE_MESSAGE); + await expectApiError(query({max_effective_mint: '1'}), 400, SCOPE_MESSAGE); + await expectApiError(query({sort: 'effective_mint'}), 400, SCOPE_MESSAGE); + }); + + txit('rejects a minimum above the maximum', async () => { + const c = await setup(); + + await expectApiError(query({collection_name: c, min_effective_mint: '5', max_effective_mint: '4'}), 400, RANGE_MESSAGE); + }); + }); +} diff --git a/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts index 70e2f906..e65aae33 100644 --- a/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/auctions.integration.test.ts @@ -3,7 +3,8 @@ import {expect} from 'chai'; import {initAtomicMarketTest} from '../test'; import {RequestValues} from '../../utils'; import {getTestContext} from '../../../../utils/test'; -import {getAuctionAction, getAuctionsAction} from './auctions'; +import {getAuctionAction, getAuctionsAction, getAuctionsCountAction} from './auctions'; +import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; import {AuctionApiState} from '../index'; import sinon from 'sinon'; import {clearMarketVersionCache, MARKET_VERSION_CACHE_TTL_MS} from '../market-version'; @@ -335,6 +336,25 @@ describe('auction handler', () => { }); }); + defineEffectiveMintSuite({ + client, + txit, + makeListing: async (collectionName, assets, templateMint) => { + const listing = await client.createAuction({ + collection_name: collectionName, + ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + }); + for (const [i, spec] of assets.entries()) { + const asset_id = await createMintAsset(client, collectionName, spec); + await client.createAuctionAssets({auction_id: listing.auction_id, asset_id, index: i + 1}); + } + + return listing.auction_id; + }, + query: async (values) => await getAuctionsIds(values), + count: async (values) => Number(await getAuctionsCountAction({...values}, getTestContext(client))), + }); + after(async () => { await client.end(); }); diff --git a/src/api/namespaces/atomicmarket/handlers/auctions.ts b/src/api/namespaces/atomicmarket/handlers/auctions.ts index 76bcc352..ffd23ef3 100644 --- a/src/api/namespaces/atomicmarket/handlers/auctions.ts +++ b/src/api/namespaces/atomicmarket/handlers/auctions.ts @@ -1,7 +1,7 @@ import {buildBoundaryFilter, RequestValues} from '../../utils'; import {AtomicMarketContext} from '../index'; import QueryBuilder from '../../../builder'; -import {buildAuctionFilter} from '../utils'; +import {AUCTION_ASSETS_MINT_SOURCE, buildAuctionFilter, buildEffectiveMintSort} from '../utils'; import {buildGreylistFilter} from '../../atomicassets/utils'; import {fillAuctions} from '../filler'; import {formatAuction} from '../format'; @@ -19,7 +19,7 @@ export async function getAuctionsAction(params: RequestValues, ctx: AtomicMarket type: 'string', allowedValues: [ 'created', 'updated', 'ending', 'auction_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' }, @@ -61,6 +61,11 @@ export async function getAuctionsAction(params: RequestValues, ctx: AtomicMarket args.sort === 'updated' ? 'listing.updated_at_time' : 'listing.created_at_time' ); + // Added before the count so the count equals the rows this sort can return. + const effectiveMintColumn = args.sort === 'effective_mint' + ? await buildEffectiveMintSort(params, query, AUCTION_ASSETS_MINT_SOURCE) + : ''; + if (args.count) { const countQuery = await ctx.db.query( 'SELECT COUNT(*) counter FROM (' + query.buildString() + ') x', @@ -77,6 +82,7 @@ export async function getAuctionsAction(params: RequestValues, ctx: AtomicMarket updated: {column: 'listing.updated_at_time', nullable: false}, price: {column: 'listing.price', nullable: true}, template_mint: {column: 'LOWER(listing.template_mint)', nullable: true}, + effective_mint: {column: effectiveMintColumn, nullable: false}, name: {column: '(COALESCE(template.mutable_data, \'{}\') || COALESCE(asset.mutable_data, \'{}\') || COALESCE(asset.immutable_data, \'{}\') || COALESCE(template.immutable_data, \'{}\'))->>\'name\'', nullable: true}, }; diff --git a/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts index 58b6706c..ef0379f0 100644 --- a/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/buyoffers.integration.test.ts @@ -2,7 +2,8 @@ import {expect} from 'chai'; import {initAtomicMarketTest} from '../test'; import {RequestValues} from '../../utils'; import {getTestContext} from '../../../../utils/test'; -import {getBuyOfferAction, getBuyOffersAction} from './buyoffers'; +import {getBuyOfferAction, getBuyOffersAction, getBuyOffersCountAction} from './buyoffers'; +import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; import {ApiError} from '../../../error'; // TODO add more tests @@ -161,6 +162,25 @@ describe('buy offer handler', () => { }); }); + defineEffectiveMintSuite({ + client, + txit, + makeListing: async (collectionName, assets, templateMint) => { + const listing = await client.createBuyOffer({ + collection_name: collectionName, + ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + }); + for (const [i, spec] of assets.entries()) { + const asset_id = await createMintAsset(client, collectionName, spec); + await client.createBuyOfferAssets({buyoffer_id: listing.buyoffer_id, asset_id, index: i + 1}); + } + + return listing.buyoffer_id; + }, + query: async (values) => await getBuyOffersIds(values), + count: async (values) => Number(await getBuyOffersCountAction({...values}, getTestContext(client))), + }); + after(async () => { await client.end(); }); diff --git a/src/api/namespaces/atomicmarket/handlers/buyoffers.ts b/src/api/namespaces/atomicmarket/handlers/buyoffers.ts index c835558f..5ab1eec0 100644 --- a/src/api/namespaces/atomicmarket/handlers/buyoffers.ts +++ b/src/api/namespaces/atomicmarket/handlers/buyoffers.ts @@ -1,7 +1,7 @@ import { buildBoundaryFilter, RequestValues } from '../../utils'; import { AtomicMarketContext } from '../index'; import QueryBuilder from '../../../builder'; -import { buildBuyofferFilter } from '../utils'; +import { buildBuyofferFilter, buildEffectiveMintSort, BUYOFFER_ASSETS_MINT_SOURCE } from '../utils'; import { buildGreylistFilter } from '../../atomicassets/utils'; import { fillBuyoffers } from '../filler'; import { formatBuyoffer } from '../format'; @@ -18,7 +18,7 @@ export async function getBuyOffersAction(params: RequestValues, ctx: AtomicMarke type: 'string', allowedValues: [ 'created', 'updated', 'buyoffer_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' }, @@ -65,6 +65,11 @@ export async function getBuyOffersAction(params: RequestValues, ctx: AtomicMarke args.sort === 'updated' ? 'listing.updated_at_time' : 'listing.created_at_time' ); + // Added before the count so the count equals the rows this sort can return. + const effectiveMintColumn = args.sort === 'effective_mint' + ? await buildEffectiveMintSort(params, query, BUYOFFER_ASSETS_MINT_SOURCE) + : ''; + if (args.count) { const countQuery = await ctx.db.query( 'SELECT COUNT(*) counter FROM (' + query.buildString() + ') x', @@ -80,6 +85,7 @@ export async function getBuyOffersAction(params: RequestValues, ctx: AtomicMarke updated: {column: 'listing.updated_at_time', nullable: false}, price: {column: 'listing.price', nullable: false}, template_mint: {column: 'LOWER(listing.template_mint)', nullable: true}, + effective_mint: {column: effectiveMintColumn, nullable: false}, name: {column: '(COALESCE(template.mutable_data, \'{}\') || COALESCE(asset.mutable_data, \'{}\') || COALESCE(asset.immutable_data, \'{}\') || COALESCE(template.immutable_data, \'{}\'))->>\'name\'', nullable: true}, }; diff --git a/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts index d1e953ac..3287c732 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales.get-sales.integration.test.ts @@ -1,6 +1,7 @@ import 'mocha'; import {expect} from 'chai'; -import {getSaleAction, getSalesAction} from './sales'; +import {getSaleAction, getSalesAction, getSalesCountAction} from './sales'; +import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; import {SaleApiState} from '../index'; import {OfferState} from '../../../../filler/handlers/atomicassets'; import {SaleState} from '../../../../filler/handlers/atomicmarket'; @@ -771,5 +772,24 @@ describe('AtomicMarket Sales API', () => { }); }); + defineEffectiveMintSuite({ + client, + txit, + makeListing: async (collectionName, assets, templateMint) => { + const {offer_id} = await client.createOffer(); + for (const [i, spec] of assets.entries()) { + const asset_id = await createMintAsset(client, collectionName, spec); + await client.createOfferAsset({offer_id, asset_id, index: i + 1}); + } + + return (await client.createSale({ + offer_id, collection_name: collectionName, + ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + })).sale_id; + }, + query: async (values) => await getSalesIds(values), + count: async (values) => Number(await getSalesCountAction({...values}, getTestContext(client))), + }); + after(async () => await client.end()); }); diff --git a/src/api/namespaces/atomicmarket/handlers/sales.ts b/src/api/namespaces/atomicmarket/handlers/sales.ts index 2f66413f..90b94d74 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales.ts @@ -4,7 +4,7 @@ import { ApiError } from '../../../error'; import { AtomicMarketContext } from '../index'; import { applyActionGreylistFilters, getContractActionLogs } from '../../../utils'; import QueryBuilder from '../../../builder'; -import {buildSaleFilter} from '../utils'; +import {buildEffectiveMintSort, buildSaleFilter, OFFER_ASSETS_MINT_SOURCE} from '../utils'; import {buildGreylistFilter} from '../../atomicassets/utils'; import { filterQueryArgs } from '../../validation'; import { fillSales } from '../filler'; @@ -63,7 +63,7 @@ export async function getSalesAction(params: RequestValues, ctx: AtomicMarketCon type: 'string', allowedValues: [ 'created', 'updated', 'sale_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' }, @@ -102,6 +102,11 @@ export async function getSalesAction(params: RequestValues, ctx: AtomicMarketCon args.sort === 'updated' ? 'listing.updated_at_time' : 'listing.created_at_time' ); + // Added before the count so the count equals the rows this sort can return. + const effectiveMintColumn = args.sort === 'effective_mint' + ? await buildEffectiveMintSort(params, query, OFFER_ASSETS_MINT_SOURCE) + : ''; + if (args.count) { const countQuery = await ctx.db.query( 'SELECT COUNT(*) counter FROM (' + query.buildString() + ') x', @@ -117,6 +122,7 @@ export async function getSalesAction(params: RequestValues, ctx: AtomicMarketCon updated: {column: 'listing.updated_at_time', nullable: false}, price: {column: 'listing.final_price', nullable: true}, template_mint: {column: 'LOWER(listing.template_mint)', nullable: true}, + effective_mint: {column: effectiveMintColumn, nullable: false}, name: {column: '(COALESCE(template.mutable_data, \'{}\') || COALESCE(asset.mutable_data, \'{}\') || COALESCE(asset.immutable_data, \'{}\') || COALESCE(template.immutable_data, \'{}\'))->>\'name\'', nullable: true}, }; diff --git a/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts index 6b5a8e8e..1f63109f 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales2.get-sales-templates.integration.test.ts @@ -4,6 +4,8 @@ import {RequestValues} from '../../utils'; import {initAtomicMarketTest} from '../test'; import {getTestContext} from '../../../../utils/test'; import {getSalesTemplatesV2Action} from './sales2'; +import {ApiError} from '../../../error'; +import {createMintAsset} from '../effective-mint-suite'; const {client, txit} = initAtomicMarketTest(); @@ -419,5 +421,65 @@ describe('AtomicMarket Sales API', () => { }); }); + describe('effective mint filter', () => { + async function templateSale(collectionName: string, spec: {template?: number, original?: number | null}): Promise { + const {template_id} = await client.createTemplate({collection_name: collectionName}); + const asset_id = await createMintAsset(client, collectionName, spec, {template_id}); + const {offer_id} = await client.createOffer(); + await client.createOfferAsset({offer_id, asset_id}); + + return (await client.createSale({offer_id, collection_name: collectionName})).sale_id; + } + + txit('filters by the original mint, else the template mint', async () => { + const c = (await client.createCollection()).collection_name; + const linked = await templateSale(c, {original: 5, template: 500}); + const unlinked = await templateSale(c, {template: 6}); + const originalWins = await templateSale(c, {original: 500, template: 5}); + const nullLink = await templateSale(c, {original: null, template: 60}); + await templateSale(c, {}); + + expect((await getSalesIds({collection_name: c, min_effective_mint: '4', max_effective_mint: '7'})).sort()) + .to.deep.equal([linked, unlinked].sort()); + expect((await getSalesIds({collection_name: c, min_effective_mint: '7', max_effective_mint: '1000'})).sort()) + .to.deep.equal([originalWins, nullLink].sort()); + expect(await getSalesIds({collection_name: c, max_effective_mint: '5'})).to.deep.equal([linked]); + }); + + txit('combines a template mint bound with an effective mint bound of the same value', async () => { + const c = (await client.createCollection()).collection_name; + const both = await templateSale(c, {original: 5, template: 5}); + await templateSale(c, {original: 50, template: 5}); + await templateSale(c, {original: 6, template: 50}); + + expect(await getSalesIds({ + collection_name: c, + min_template_mint: '5', min_effective_mint: '5', + max_template_mint: '6', max_effective_mint: '6', + })).to.deep.equal([both]); + }); + + txit('requires collection_name and a valid range', async () => { + const c = (await client.createCollection()).collection_name; + await templateSale(c, {original: 5}); + + for (const [values, message] of [ + [{collection_whitelist: c, min_effective_mint: '1'}, 'The effective mint filters and sort require collection_name'], + [{collection_name: c, min_effective_mint: '5', max_effective_mint: '4'}, 'Min effective mint can\'t be greater than max effective mint'], + ]) { + let err: any; + try { + await getSalesIds(values); + } catch (e) { + err = e; + } + + expect(err).to.be.instanceof(ApiError); + expect(err.code).to.equal(400); + expect(err.message).to.equal(message); + } + }); + }); + after(async () => await client.end()); }); diff --git a/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts index 09e85de6..eb3b6102 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales2.integration.test.ts @@ -3,7 +3,8 @@ import { expect } from 'chai'; import { RequestValues } from '../../utils'; import { initAtomicMarketTest } from '../test'; import { getTestContext } from '../../../../utils/test'; -import { getSalesV2Action } from './sales2'; +import { getSalesCountV2Action, getSalesV2Action } from './sales2'; +import {createMintAsset, defineEffectiveMintSuite, MintAssetSpec} from '../effective-mint-suite'; import { SaleApiState } from '../index'; import { OfferState } from '../../../../filler/handlers/atomicassets'; import { SaleState } from '../../../../filler/handlers/atomicmarket'; @@ -1029,6 +1030,54 @@ describe('AtomicMarket Sales API', () => { }); }); + defineEffectiveMintSuite({ + client, + txit, + makeListing: async (collectionName, assets, templateMint) => { + const {offer_id} = await client.createOffer(); + for (const [i, spec] of assets.entries()) { + const asset_id = await createMintAsset(client, collectionName, spec); + await client.createOfferAsset({offer_id, asset_id, index: i + 1}); + } + + return (await client.createSale({ + offer_id, collection_name: collectionName, + ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + })).sale_id; + }, + query: async (values) => await getSalesIds(values), + count: async (values) => { + await client.refreshSalesFilters(); + + return Number(await getSalesCountV2Action({...values}, getTestContext(client))); + }, + }); + + describe('effective mint sort with a state filter', () => { + async function listedSale(collectionName: string, spec: MintAssetSpec): Promise { + const { offer_id } = await client.createOffer(); + const asset_id = await createMintAsset(client, collectionName, spec); + await client.createOfferAsset({ offer_id, asset_id }); + + return (await client.createSale({ offer_id, collection_name: collectionName })).sale_id; + } + + txit('orders and paginates through the state recheck', async () => { + const c = (await client.createCollection()).collection_name; + const high = await listedSale(c, { template: 30 }); + const low = await listedSale(c, { original: 2, template: 99 }); + const middle = await listedSale(c, { original: 8 }); + await listedSale(c, {}); + + const params = { collection_name: c, state: String(SaleApiState.LISTED), sort: 'effective_mint', order: 'asc' }; + + expect(await getSalesIds(params)).to.deep.equal([low, middle, high]); + expect(await getSalesIds({ ...params, limit: '1', page: '2' })).to.deep.equal([middle]); + expect(await getSalesIds({ ...params, min_effective_mint: '1', max_effective_mint: '10' })) + .to.deep.equal([low, middle]); + }); + }); + after(async () => { await client.end(); }); diff --git a/src/api/namespaces/atomicmarket/handlers/sales2.ts b/src/api/namespaces/atomicmarket/handlers/sales2.ts index c22887f3..8fc3efb6 100644 --- a/src/api/namespaces/atomicmarket/handlers/sales2.ts +++ b/src/api/namespaces/atomicmarket/handlers/sales2.ts @@ -8,7 +8,7 @@ import {toInt} from '../../../../utils'; import moize from 'moize'; import {filterQueryArgs, FilterValues} from '../../validation'; import {hasAssetFilter} from '../../atomicassets/utils'; -import {buildTemplateMintFilter} from '../utils'; +import {buildEffectiveMintFilter, buildEffectiveMintSort, buildTemplateMintFilter, SALES_FILTER_MINT_SOURCE} from '../utils'; type SalesSearchOptions = { values: FilterValues; @@ -31,7 +31,7 @@ export async function getSalesV2Action(params: RequestValues, ctx: AtomicMarketC type: 'string', allowedValues: [ 'created', 'updated', 'sale_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' }, @@ -65,6 +65,11 @@ export async function getSalesV2Action(params: RequestValues, ctx: AtomicMarketC search.strongFilters.push('ids'); } + // Added before the count so the count equals the rows this sort can return. + const effectiveMintColumn = args.sort === 'effective_mint' + ? await buildEffectiveMintSort(params, query, SALES_FILTER_MINT_SOURCE) + : ''; + if (args.count) { const countQuery = await ctx.db.query( 'SELECT COUNT(*) counter FROM (' + query.buildString() + ') x', @@ -80,6 +85,7 @@ export async function getSalesV2Action(params: RequestValues, ctx: AtomicMarketC updated: {column: 'listing.updated_at_time', numericIndex: true}, price: {column: 'listing.price', numericIndex: true}, template_mint: {column: 'LOWER(listing.template_mint)', numericIndex: true}, + effective_mint: {column: effectiveMintColumn, numericIndex: false}, name: {column: 'SPLIT_PART(listing.asset_names, e\'\\n\', 1)', nullable: true, numericIndex: false}, }; @@ -502,6 +508,7 @@ async function buildListingFilterV2(search: SalesSearchOptions): Promise { } await buildTemplateMintFilter(values, query); + await buildEffectiveMintFilter(values, query, SALES_FILTER_MINT_SOURCE); } function getDataFilters(search: SalesSearchOptions): string[] { diff --git a/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts b/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts index 5df2cd7c..8dac3a67 100644 --- a/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts +++ b/src/api/namespaces/atomicmarket/handlers/template-buyoffers.integration.test.ts @@ -2,7 +2,8 @@ import {expect} from 'chai'; import {initAtomicMarketTest} from '../test'; import {RequestValues} from '../../utils'; import {getTestContext} from '../../../../utils/test'; -import {getTemplateBuyOfferAction, getTemplateBuyOffersAction} from './template-buyoffers'; +import {getTemplateBuyOfferAction, getTemplateBuyOffersAction, getTemplateBuyOffersCountAction} from './template-buyoffers'; +import {createMintAsset, defineEffectiveMintSuite} from '../effective-mint-suite'; import {ApiError} from '../../../error'; // TODO add more tests @@ -232,6 +233,26 @@ describe('template buy offer handler', () => { }); }); + defineEffectiveMintSuite({ + client, + txit, + makeListing: async (collectionName, assets, templateMint) => { + const {template_id} = await client.createTemplate({collection_name: collectionName}); + const listing = await client.createTemplateBuyOffer({ + collection_name: collectionName, template_id, + ...(templateMint === undefined ? {} : {template_mint: `[${templateMint},${templateMint}]`}), + }); + for (const [i, spec] of assets.entries()) { + const asset_id = await createMintAsset(client, collectionName, spec); + await client.createTemplateBuyOfferAssets({buyoffer_id: listing.buyoffer_id, asset_id, index: i + 1}); + } + + return listing.buyoffer_id; + }, + query: async (values) => await getBuyOffersIds(values), + count: async (values) => Number(await getTemplateBuyOffersCountAction({...values}, getTestContext(client))), + }); + after(async () => { await client.end(); }); diff --git a/src/api/namespaces/atomicmarket/handlers/template-buyoffers.ts b/src/api/namespaces/atomicmarket/handlers/template-buyoffers.ts index 5f7ddb89..a3fd5af9 100644 --- a/src/api/namespaces/atomicmarket/handlers/template-buyoffers.ts +++ b/src/api/namespaces/atomicmarket/handlers/template-buyoffers.ts @@ -1,7 +1,7 @@ import { buildBoundaryFilter, RequestValues } from '../../utils'; import { AtomicMarketContext } from '../index'; import QueryBuilder from '../../../builder'; -import { buildTemplateBuyofferFilter } from '../utils'; +import { buildEffectiveMintSort, buildTemplateBuyofferFilter, TEMPLATE_BUYOFFER_ASSETS_MINT_SOURCE } from '../utils'; import { buildGreylistFilter } from '../../atomicassets/utils'; import { fillTemplateBuyoffers } from '../filler'; import { formatTemplateBuyoffer } from '../format'; @@ -18,7 +18,7 @@ export async function getTemplateBuyOffersAction(params: RequestValues, ctx: Ato type: 'string', allowedValues: [ 'created', 'updated', 'buyoffer_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' }, @@ -50,6 +50,11 @@ export async function getTemplateBuyOffersAction(params: RequestValues, ctx: Ato args.sort === 'updated' ? 'listing.updated_at_time' : 'listing.created_at_time' ); + // Added before the count so the count equals the rows this sort can return. + const effectiveMintColumn = args.sort === 'effective_mint' + ? await buildEffectiveMintSort(params, query, TEMPLATE_BUYOFFER_ASSETS_MINT_SOURCE) + : ''; + if (args.count) { const countQuery = await ctx.db.query( 'SELECT COUNT(*) counter FROM (' + query.buildString() + ') x', @@ -65,6 +70,7 @@ export async function getTemplateBuyOffersAction(params: RequestValues, ctx: Ato updated: {column: 'listing.updated_at_time', nullable: false}, price: {column: 'listing.price', nullable: false}, template_mint: {column: 'LOWER(listing.template_mint)', nullable: true}, + effective_mint: {column: effectiveMintColumn, nullable: false}, name: {column: '(COALESCE(template.mutable_data, \'{}\') || COALESCE(asset.mutable_data, \'{}\') || COALESCE(asset.immutable_data, \'{}\') || COALESCE(template.immutable_data, \'{}\'))->>\'name\'', nullable: true}, }; diff --git a/src/api/namespaces/atomicmarket/openapi.ts b/src/api/namespaces/atomicmarket/openapi.ts index 5185fe1f..204445bc 100644 --- a/src/api/namespaces/atomicmarket/openapi.ts +++ b/src/api/namespaces/atomicmarket/openapi.ts @@ -387,6 +387,25 @@ export const atomicmarketComponents = { } }; +export const effectiveMintParameters = [ + { + name: 'min_effective_mint', + in: 'query', + description: 'Min mint of the listing assets: the original mint of a bridged asset, else the template mint. ' + + 'Requires collection_name.', + required: false, + schema: {type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER} + }, + { + name: 'max_effective_mint', + in: 'query', + description: 'Max mint of the listing assets: the original mint of a bridged asset, else the template mint. ' + + 'Requires collection_name.', + required: false, + schema: {type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER} + } +]; + export const listingFilterParameters = [ { name: 'max_assets', @@ -513,5 +532,6 @@ export const listingFilterParameters = [ description: 'Max template mint', required: false, schema: {type: 'number'} - } + }, + ...effectiveMintParameters ]; diff --git a/src/api/namespaces/atomicmarket/routes/auctions.ts b/src/api/namespaces/atomicmarket/routes/auctions.ts index 9647edcd..826a4239 100644 --- a/src/api/namespaces/atomicmarket/routes/auctions.ts +++ b/src/api/namespaces/atomicmarket/routes/auctions.ts @@ -104,7 +104,7 @@ export function auctionsEndpoints(core: AtomicMarketNamespace, server: HTTPServe type: 'string', enum: [ 'created', 'updated', 'ending', 'auction_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' } diff --git a/src/api/namespaces/atomicmarket/routes/buyoffers.ts b/src/api/namespaces/atomicmarket/routes/buyoffers.ts index f4d346b7..1dea4071 100644 --- a/src/api/namespaces/atomicmarket/routes/buyoffers.ts +++ b/src/api/namespaces/atomicmarket/routes/buyoffers.ts @@ -77,7 +77,7 @@ export function buyoffersEndpoints(core: AtomicMarketNamespace, server: HTTPServ type: 'string', enum: [ 'created', 'updated', 'buyoffer_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' } diff --git a/src/api/namespaces/atomicmarket/routes/sales.ts b/src/api/namespaces/atomicmarket/routes/sales.ts index ac3cd76c..e81ea56e 100644 --- a/src/api/namespaces/atomicmarket/routes/sales.ts +++ b/src/api/namespaces/atomicmarket/routes/sales.ts @@ -12,7 +12,7 @@ import { getPrimaryBoundaryParams, paginationParameters } from '../../../docs'; -import { listingFilterParameters } from '../openapi'; +import { effectiveMintParameters, listingFilterParameters } from '../openapi'; import { createSocketApiNamespace, extractNotificationIdentifiers, @@ -99,7 +99,7 @@ export function salesEndpoints(core: AtomicMarketNamespace, server: HTTPServer, type: 'string', enum: [ 'created', 'updated', 'sale_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' } @@ -140,6 +140,7 @@ export function salesEndpoints(core: AtomicMarketNamespace, server: HTTPServer, }, ...baseAssetFilterParameters, ...extendedAssetFilterParameters, + ...effectiveMintParameters, ...paginationParameters, { name: 'sort', diff --git a/src/api/namespaces/atomicmarket/routes/template-buyoffers.ts b/src/api/namespaces/atomicmarket/routes/template-buyoffers.ts index 77b6c650..f4b22d05 100644 --- a/src/api/namespaces/atomicmarket/routes/template-buyoffers.ts +++ b/src/api/namespaces/atomicmarket/routes/template-buyoffers.ts @@ -75,7 +75,7 @@ export function templateBuyoffersEndpoints(core: AtomicMarketNamespace, server: type: 'string', enum: [ 'created', 'updated', 'buyoffer_id', 'price', - 'template_mint', 'name', + 'template_mint', 'effective_mint', 'name', ], default: 'created' } diff --git a/src/api/namespaces/atomicmarket/utils.ts b/src/api/namespaces/atomicmarket/utils.ts index e0d646d1..97f3e921 100644 --- a/src/api/namespaces/atomicmarket/utils.ts +++ b/src/api/namespaces/atomicmarket/utils.ts @@ -6,7 +6,7 @@ import QueryBuilder from '../../builder'; import {ApiError} from '../../error'; import {filterQueryArgs, FilterValues} from '../validation'; -async function buildListingFilter(values: FilterValues, query: QueryBuilder): Promise { +async function buildListingFilter(values: FilterValues, query: QueryBuilder, mintSource: EffectiveMintSource): Promise { const args = await filterQueryArgs(values, { show_seller_contracts: {type: 'bool', default: true}, contract_whitelist: {type: 'list[name]', default: ['']}, @@ -75,8 +75,101 @@ async function buildListingFilter(values: FilterValues, query: QueryBuilder): Pr } await buildTemplateMintFilter(values, query); + await buildEffectiveMintFilter(values, query, mintSource); } +/** The rows of one listing's assets, with the asset as `a` and its link row as `om`. */ +export type EffectiveMintSource = {from: string, where: string}; + +function listingAssetsMintSource( + table: string, contractColumn: string, where: string +): EffectiveMintSource { + return { + from: `${table} la ` + + `JOIN atomicassets_assets a ON a.contract = la.${contractColumn} AND a.asset_id = la.asset_id ` + + `LEFT JOIN atomicassets_original_mints om ON om.contract = la.${contractColumn} AND om.asset_id = la.asset_id`, + where, + }; +} + +export const OFFER_ASSETS_MINT_SOURCE = listingAssetsMintSource( + 'atomicassets_offers_assets', 'contract', 'la.contract = listing.assets_contract AND la.offer_id = listing.offer_id' +); +export const AUCTION_ASSETS_MINT_SOURCE = listingAssetsMintSource( + 'atomicmarket_auctions_assets', 'assets_contract', + 'la.market_contract = listing.market_contract AND la.auction_id = listing.auction_id' +); +export const BUYOFFER_ASSETS_MINT_SOURCE = listingAssetsMintSource( + 'atomicmarket_buyoffers_assets', 'assets_contract', + 'la.market_contract = listing.market_contract AND la.buyoffer_id = listing.buyoffer_id' +); +export const TEMPLATE_BUYOFFER_ASSETS_MINT_SOURCE = listingAssetsMintSource( + 'atomicmarket_template_buyoffers_assets', 'assets_contract', + 'la.market_contract = listing.market_contract AND la.buyoffer_id = listing.buyoffer_id' +); + +// The sales filter table holds the asset ids of the listing itself. +export const SALES_FILTER_MINT_SOURCE: EffectiveMintSource = { + from: 'atomicassets_assets a ' + + 'LEFT JOIN atomicassets_original_mints om ON om.contract = a.contract AND om.asset_id = a.asset_id', + where: 'a.contract = listing.assets_contract AND a.asset_id = ANY(listing.asset_ids)', +}; + +const EFFECTIVE_MINT = 'COALESCE(om.original_mint, a.template_mint)'; + +async function requireMintScope(values: FilterValues): Promise { + const args = await filterQueryArgs(values, {collection_name: {type: 'list[name]'}}); + + // Without a collection each listing needs its own probe of the assets and link + // tables, which reads those tables end to end on a large chain. + if (!args.collection_name.length) { + throw new ApiError('The effective mint filters and sort require collection_name', 400); + } +} + +// One scalar aggregate per listing: the planner cannot reorder it, and the join +// and array-overlap forms measured far slower on production-size sales tables. +export async function buildEffectiveMintFilter( + values: FilterValues, query: QueryBuilder, source: EffectiveMintSource +): Promise { + const args = await filterQueryArgs(values, { + min_effective_mint: {type: 'int', min: 1, max: Number.MAX_SAFE_INTEGER}, + max_effective_mint: {type: 'int', min: 1, max: Number.MAX_SAFE_INTEGER}, + }); + + if (args.min_effective_mint === undefined && args.max_effective_mint === undefined) { + return; + } + + await requireMintScope(values); + + if (args.max_effective_mint !== undefined && (args.min_effective_mint ?? 0) > args.max_effective_mint) { + throw new ApiError('Min effective mint can\'t be greater than max effective mint', 400); + } + + // HAVING makes a listing without any mint a NULL range, which never matches. + query.addCondition( + `(SELECT int8range(MIN(${EFFECTIVE_MINT}), MAX(${EFFECTIVE_MINT}), '[]') ` + + `FROM ${source.from} WHERE ${source.where} ` + + `HAVING COUNT(${EFFECTIVE_MINT}) > 0) <@ int8range(` + + query.addVariable(args.min_effective_mint ?? null) + ', ' + + query.addVariable(args.max_effective_mint ?? null) + ', \'[]\')' + ); +} + +/** Adds the sort's listing condition and returns the sort column. Call it before the count branch. */ +export async function buildEffectiveMintSort( + values: FilterValues, query: QueryBuilder, source: EffectiveMintSource +): Promise { + await requireMintScope(values); + + const column = `(SELECT MIN(${EFFECTIVE_MINT}) FROM ${source.from} WHERE ${source.where})`; + query.addCondition(`${column} IS NOT NULL`); + + return column; +} + + export async function buildTemplateMintFilter(values: FilterValues, query: QueryBuilder): Promise { const args = await filterQueryArgs(values, { min_template_mint: {type: 'int', min: 1}, @@ -119,7 +212,7 @@ export async function buildSaleFilter(values: FilterValues, query: QueryBuilder) throw new ApiError('Price filters are removed in /v1/sales, use /v2/sales', 400); } - await buildListingFilter(values, query); + await buildListingFilter(values, query, OFFER_ASSETS_MINT_SOURCE); if (args.template_blacklist.length || hasAssetFilter(values, ['collection_name']) || hasDataFilters(values)) { const assetQuery = new QueryBuilder( @@ -233,7 +326,7 @@ export async function buildAuctionFilter(values: FilterValues, query: QueryBuild template_blacklist: {type: 'list[id]'}, }); - await buildListingFilter(values, query); + await buildListingFilter(values, query, AUCTION_ASSETS_MINT_SOURCE); if (args.hide_templates_by_accounts.length || args.template_blacklist.length || hasAssetFilter(values, ['collection_name']) || hasDataFilters(values)) { const assetQuery = new QueryBuilder( @@ -394,7 +487,7 @@ export async function buildBuyofferFilter(values: FilterValues, query: QueryBuil max_price: {type: 'float', min: 0} }); - await buildListingFilter(values, query); + await buildListingFilter(values, query, BUYOFFER_ASSETS_MINT_SOURCE); if (hasAssetFilter(values, ['collection_name']) || hasDataFilters(values)) { const assetQuery = new QueryBuilder( @@ -513,7 +606,7 @@ export async function buildTemplateBuyofferFilter(values: FilterValues, query: Q template_id: {type: 'list[id]'}, }); - await buildListingFilter(values, query); + await buildListingFilter(values, query, TEMPLATE_BUYOFFER_ASSETS_MINT_SOURCE); if (hasAssetFilter(values, ['collection_name', 'template_id']) || hasDataFilters(values)) { const assetQuery = new QueryBuilder(