From ffe289c1a2e121058d1c2adf1256e8b13c88d111 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 5 Oct 2026 20:50:25 -0400 Subject: [PATCH] feat(atomicmarket): filter and sort listings by effective mint so one parameter ranges over the mint a bridged or a plain asset shows A bridged asset shows its original mint and a plain asset shows its template mint, but the market endpoints could filter and sort only by the template mint. A client that filtered a collection with both kinds of asset by the mint on the card got the wrong listings for every bridged asset. The sales, auctions, buyoffers and template buyoffers lists and their counts now accept min_effective_mint and max_effective_mint, the lists accept sort=effective_mint, and the sales templates list accepts the two filters. The effective mint of an asset is its original mint when a link row with a mint exists, else its template mint. A listing matches when at least one asset has an effective mint and every such asset lies in the range, so a listing of a plain asset stays in the result. The sort uses the lowest effective mint of the listing and leaves out a listing with none, and the count applies the same condition. The rule is one scalar aggregate per listing. The planner cannot reorder it, where a join form let it build a hash of the link rows once per listing and an array overlap form grew with the range. No index holds the value, so each listing in scope costs a probe of the asset and link tables. A request with one of the three parameters and no collection_name returns 400 for that reason: without the scope the probe runs for every listing of the chain. The template_mint filters and sorts keep their query text. The release has no migration. Signed-off-by: Rob Konsdorf --- CHANGELOG.md | 16 ++ .../atomicmarket/effective-mint-suite.ts | 192 ++++++++++++++++++ .../handlers/auctions.integration.test.ts | 22 +- .../atomicmarket/handlers/auctions.ts | 10 +- .../handlers/buyoffers.integration.test.ts | 22 +- .../atomicmarket/handlers/buyoffers.ts | 10 +- .../sales.get-sales.integration.test.ts | 22 +- .../namespaces/atomicmarket/handlers/sales.ts | 10 +- ...s2.get-sales-templates.integration.test.ts | 62 ++++++ .../handlers/sales2.integration.test.ts | 51 ++++- .../atomicmarket/handlers/sales2.ts | 11 +- .../template-buyoffers.integration.test.ts | 23 ++- .../handlers/template-buyoffers.ts | 10 +- src/api/namespaces/atomicmarket/openapi.ts | 22 +- .../atomicmarket/routes/auctions.ts | 2 +- .../atomicmarket/routes/buyoffers.ts | 2 +- .../namespaces/atomicmarket/routes/sales.ts | 5 +- .../atomicmarket/routes/template-buyoffers.ts | 2 +- src/api/namespaces/atomicmarket/utils.ts | 103 +++++++++- 19 files changed, 571 insertions(+), 26 deletions(-) create mode 100644 src/api/namespaces/atomicmarket/effective-mint-suite.ts 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(