Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
192 changes: 192 additions & 0 deletions src/api/namespaces/atomicmarket/effective-mint-suite.ts
Original file line number Diff line number Diff line change
@@ -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<number>,
query: (params: Record<string, string>) => Promise<number[]>,
count: (params: Record<string, string>) => Promise<number>,
};

export async function createMintAsset(
client: AtomicMarketTestClient, collectionName: string, spec: MintAssetSpec,
assetValues: Record<string, any> = {}
): Promise<number> {
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<any>, code: number, message: string): Promise<void> {
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<string> {
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);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
Expand Down
10 changes: 8 additions & 2 deletions src/api/namespaces/atomicmarket/handlers/auctions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
},
Expand Down Expand Up @@ -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',
Expand All @@ -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},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
Expand Down
10 changes: 8 additions & 2 deletions src/api/namespaces/atomicmarket/handlers/buyoffers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
},
Expand Down Expand Up @@ -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',
Expand All @@ -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},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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());
});
Loading
Loading