From ab146071fcdc189f4e943438a7756d0d3a430df3 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:58:01 -0400 Subject: [PATCH 1/5] Report the live collection fee on auctions, buyoffers, and template buyoffers --- src/endpoints/types.ts | 21 ++++++- test/v2-api.ts | 122 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/endpoints/types.ts b/src/endpoints/types.ts index 660692e..b327ccf 100644 --- a/src/endpoints/types.ts +++ b/src/endpoints/types.ts @@ -339,7 +339,11 @@ export class SaleObject extends Struct { @Struct.field('string') declare updated_at_time: string @Struct.field(UInt64) declare created_at_block: UInt64 @Struct.field('string') declare created_at_time: string - /** Collection fee resolved at listing time. AtomicMarket v2 only. */ + /** + * The collection's `market_fee` as of the last indexed block. The nested + * `collection.market_fee` is the listing-time snapshot, and the two differ + * while the listing is open. AtomicMarket v2 only, undefined on a v1 indexer. + */ @Struct.field(Float64, {optional: true}) declare current_collection_fee: Float64 } @@ -376,6 +380,11 @@ export class AuctionObject extends Struct { @Struct.field('string') declare created_at_time: string @Struct.field(UInt64) declare updated_at_block: UInt64 @Struct.field('string') declare updated_at_time: string + /** + * The collection's live market_fee; see SaleObject.current_collection_fee. + * AtomicMarket v2 only. + */ + @Struct.field(Float64, {optional: true}) declare current_collection_fee: Float64 } @Struct.type('buyoffer_object') @@ -398,6 +407,11 @@ export class BuyofferObject extends Struct { @Struct.field(UInt64) declare updated_at_block: UInt64 @Struct.field('string') declare updated_at_time: string @Struct.field(UInt8) declare state: UInt8 + /** + * The collection's live market_fee; see SaleObject.current_collection_fee. + * AtomicMarket v2 only. + */ + @Struct.field(Float64, {optional: true}) declare current_collection_fee: Float64 } @Struct.type('template_buyoffer_object') @@ -419,6 +433,11 @@ export class TemplateBuyofferObject extends Struct { @Struct.field(UInt64) declare updated_at_block: UInt64 @Struct.field('string') declare updated_at_time: string @Struct.field(UInt8) declare state: UInt8 + /** + * The collection's live market_fee; see SaleObject.current_collection_fee. + * AtomicMarket v2 only. + */ + @Struct.field(Float64, {optional: true}) declare current_collection_fee: Float64 } @Struct.type('marketplace') diff --git a/test/v2-api.ts b/test/v2-api.ts index a85c774..caa6cc1 100644 --- a/test/v2-api.ts +++ b/test/v2-api.ts @@ -66,4 +66,126 @@ suite('v2 API response fields', function () { assert.isTrue(collection.new_author_name.equals('bob')) assert.equal(collection.new_author_date, '1785263212000') }) + + // The nested collection carries the fee the listing was created with, and + // current_collection_fee carries the fee as of the last indexed block. + const listingCollection = { + collection_name: 'royaltycol11', + author: 'alice', + allow_notify: true, + authorized_accounts: [], + notify_accounts: [], + market_fee: 0.05, + created_at_block: 1, + created_at_time: '1', + } + + const listingPrice = { + token_contract: 'eosio.token', + token_symbol: 'WAX', + token_precision: 8, + amount: '1250000', + } + + const listingTemplate = { + template_id: 662912, + is_transferable: true, + is_burnable: true, + issued_supply: 1, + max_supply: 10, + immutable_data: {name: 'Test'}, + created_at_block: 1, + created_at_time: '1', + } + + test('auctions, buyoffers, and template buyoffers report the live collection fee', function () { + const auction = Types.AuctionObject.from({ + market_contract: 'atomicmarket', + assets_contract: 'atomicassets', + auction_id: 1, + seller: 'alice', + assets: [], + end_time: '1783387748000', + price: listingPrice, + bids: [], + state: 1, + claimed_by_seller: false, + claimed_by_buyer: false, + collection: listingCollection, + is_seller_contract: false, + created_at_block: 1, + created_at_time: '1', + updated_at_block: 2, + updated_at_time: '2', + current_collection_fee: 0.07, + }) + + const buyoffer = Types.BuyofferObject.from({ + market_contract: 'atomicmarket', + assets_contract: 'atomicassets', + buyoffer_id: 2, + seller: 'alice', + buyer: 'bob', + price: listingPrice, + assets: [], + collection: listingCollection, + memo: '', + created_at_block: 1, + created_at_time: '1', + updated_at_block: 2, + updated_at_time: '2', + state: 0, + current_collection_fee: 0.07, + }) + + const templateBuyoffer = Types.TemplateBuyofferObject.from({ + market_contract: 'atomicmarket', + assets_contract: 'atomicassets', + buyoffer_id: 3, + buyer: 'bob', + price: listingPrice, + assets: [], + collection: listingCollection, + template: listingTemplate, + created_at_block: 1, + created_at_time: '1', + updated_at_block: 2, + updated_at_time: '2', + state: 0, + current_collection_fee: 0.07, + }) + + assert.equal(auction.current_collection_fee.value, 0.07) + assert.equal(buyoffer.current_collection_fee.value, 0.07) + assert.equal(templateBuyoffer.current_collection_fee.value, 0.07) + + assert.equal(auction.collection.market_fee.value, 0.05) + assert.equal(buyoffer.collection.market_fee.value, 0.05) + assert.equal(templateBuyoffer.collection.market_fee.value, 0.05) + }) + + test('an auction from a v1 indexer decodes without the collection fee', function () { + const auction = Types.AuctionObject.from({ + market_contract: 'atomicmarket', + assets_contract: 'atomicassets', + auction_id: 1, + seller: 'alice', + assets: [], + end_time: '1783387748000', + price: listingPrice, + bids: [], + state: 1, + claimed_by_seller: false, + claimed_by_buyer: false, + collection: listingCollection, + is_seller_contract: false, + created_at_block: 1, + created_at_time: '1', + updated_at_block: 2, + updated_at_time: '2', + }) + + assert.isUndefined(auction.current_collection_fee) + assert.equal(auction.collection.market_fee.value, 0.05) + }) }) From f1274d5a080e9186b3805f751e645f733f39453d Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:58:01 -0400 Subject: [PATCH 2/5] Report the media-type descriptors the schema endpoints return --- src/endpoints/types.ts | 40 +++++++++++++++++++++++++++++++++++----- src/objects/schema.ts | 9 +++++++++ test/schema.ts | 28 ++++++++++++++++++++++++++-- test/v2-api.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/endpoints/types.ts b/src/endpoints/types.ts index b327ccf..ab198c9 100644 --- a/src/endpoints/types.ts +++ b/src/endpoints/types.ts @@ -136,17 +136,38 @@ export class CollectionObject extends Struct { * A schema format field as returned by the API. * * This is intentionally not the contract's `FORMAT` struct: `createschema` takes - * only `{name, type}`, while the API additionally reports the `mediatype` and - * `info` recorded by the AtomicAssets v2 `setschematyp` action. Widening the - * contract struct would let those fields leak into serialized action data. + * only `{name, type}`, while the API additionally reports a `mediatype` and an + * `info` descriptor per field. Widening the contract struct would let those + * fields leak into serialized action data. */ @Struct.type('schema_format_field') export class SchemaFormatField extends Struct { @Struct.field('string') declare name: string @Struct.field('string') declare type: string - /** Media type recorded by `setschematyp`. AtomicAssets v2 only. */ + /** + * Media type for the field. The API merges the descriptors authored through + * `setschematyp` with a name and type heuristic, so a value here can be + * derived rather than stored. `SchemaObject.types` reports the authored + * ones. AtomicAssets v2 only. + */ + @Struct.field('string', {optional: true}) declare mediatype: string + /** + * Free-form descriptor for the field, merged with the same heuristic as + * `mediatype`. `SchemaObject.types` reports the authored ones. + * AtomicAssets v2 only. + */ + @Struct.field('string', {optional: true}) declare info: string +} + +/** + * A media-type descriptor authored through `setschematyp`, exactly as stored on + * chain. It carries no serialization `type`, which is what separates it from + * `SchemaFormatField`. + */ +@Struct.type('schema_format_type') +export class SchemaFormatType extends Struct { + @Struct.field('string') declare name: string @Struct.field('string', {optional: true}) declare mediatype: string - /** Free-form descriptor recorded by `setschematyp`. AtomicAssets v2 only. */ @Struct.field('string', {optional: true}) declare info: string } @@ -156,6 +177,15 @@ export class SchemaObject extends Struct { @Struct.field(UInt64, {optional: true}) declare assets: UInt64 @Struct.field(SchemaFormatField, {array: true}) declare format: SchemaFormatField[] + /** + * The descriptors authored through `setschematyp`, unmerged. Returned by + * `/schemas` and `/schemas/{collection_name}/{schema_name}` alone, so a + * schema nested in an asset, template, or account response omits it. An + * empty array means the schema has none, and an absent one means the + * response does not report them. AtomicAssets v2 only. + */ + @Struct.field(SchemaFormatType, {array: true, optional: true}) + declare types: SchemaFormatType[] @Struct.field(Name, {optional: true}) declare contract: Name @Struct.field(Name, {optional: true}) declare collection_name: Name @Struct.field(CollectionObject, {optional: true}) declare collection: CollectionObject diff --git a/src/objects/schema.ts b/src/objects/schema.ts index 67fa38b..f55a719 100644 --- a/src/objects/schema.ts +++ b/src/objects/schema.ts @@ -33,6 +33,15 @@ export class Schema { return this.data.format } + /** + * The descriptors authored through `setschematyp`. Undefined when the + * schema came from a response that does not report them, such as the schema + * nested in an asset or template. + */ + get types() { + return this.data.types + } + extendSchema( authorizedEditor: NameType, schemaFormat: AtomicAssetsContract.ActionParams.Type.FORMAT[] diff --git a/test/schema.ts b/test/schema.ts index b40eab2..346ac50 100644 --- a/test/schema.ts +++ b/test/schema.ts @@ -5,8 +5,15 @@ import {mockFetch} from '@wharfkit/mock-data' import {PlaceholderAuth} from '@wharfkit/signing-request' import {BASE_URL, TIMEOUT, SLOW_THRESHOLD} from './config' -import type {Schema} from '$lib' -import {AtomicAssetsAPIClient, AtomicAssetsContract, AtomicAssetsKit, KitUtility, Types} from '$lib' +import { + AtomicAssetsAPIClient, + AtomicAssetsContract, + AtomicAssetsKit, + AtomicMarketContract, + KitUtility, + Schema, + Types, +} from '$lib' const client = new APIClient({ provider: new FetchProvider(Chains.WAX.url, {fetch: mockFetch}), @@ -78,6 +85,23 @@ suite('Schema', function () { assert.isNull(field.info) }) + test('types returns the authored media type descriptors', function () { + // The shared fixture comes from a v1 chain, whose schema endpoints + // report no types at all, so the descriptors are built here instead. + const schemaObject = Types.SchemaObject.from({ + schema_name: schemaName, + format: [{name: 'video', type: 'string', mediatype: 'video/mp4'}], + types: [{name: 'video', mediatype: 'video/mp4', info: 'trailer'}], + created_at_block: 1, + created_at_time: '1', + }) + const schema = Schema.from(schemaObject, utility) + + assert.instanceOf(schema.types[0], Types.SchemaFormatType) + assert.equal(schema.types[0].name, 'video') + assert.equal(schema.types[0].mediatype, 'video/mp4') + assert.equal(schema.types[0].info, 'trailer') + }) test('the API format type stays separate from the contract format type', function () { // The contract's FORMAT is what createschema and extendschema serialize, // so it must stay at {name, type}. The API reports two further fields. diff --git a/test/v2-api.ts b/test/v2-api.ts index caa6cc1..ee138bb 100644 --- a/test/v2-api.ts +++ b/test/v2-api.ts @@ -188,4 +188,34 @@ suite('v2 API response fields', function () { assert.isUndefined(auction.current_collection_fee) assert.equal(auction.collection.market_fee.value, 0.05) }) + + test('a schema reports the authored media type descriptors', function () { + const schema = Types.SchemaObject.from({ + schema_name: 'cmbz.res', + format: [{name: 'video', type: 'string', mediatype: 'video/mp4', info: 'trailer'}], + types: [{name: 'video', mediatype: 'video/mp4', info: 'trailer'}], + created_at_block: 1, + created_at_time: '1', + }) + + assert.equal(schema.types.length, 1) + assert.instanceOf(schema.types[0], Types.SchemaFormatType) + assert.equal(schema.types[0].name, 'video') + assert.equal(schema.types[0].mediatype, 'video/mp4') + assert.equal(schema.types[0].info, 'trailer') + }) + + test('a schema without the descriptors decodes and reports none', function () { + const schema = Types.SchemaObject.from({ + schema_name: 'cmbz.res', + format: [{name: 'video', type: 'string'}], + created_at_block: 1, + created_at_time: '1', + }) + + // An absent types array means the response does not report descriptors, + // which is a different answer from the empty array a schema endpoint + // sends for a schema that has none. + assert.isUndefined(schema.types) + }) }) From bd60c5c2306e7d42af90f4b18cd179474740165f Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:58:01 -0400 Subject: [PATCH 3/5] Add the AtomicMarket v2 royalty read endpoints --- src/endpoints/market/types.ts | 30 ++++ src/endpoints/market/v1.ts | 128 ++++++++++++++++++ src/endpoints/types.ts | 110 +++++++++++++++ test/config.ts | 9 +- ...a8dfbe47ba945e536c03785dd45e07b6a8297.json | 35 +++++ ...41a1006fdc5b9661c49ab5c64fc468c74cc3d.json | 60 ++++++++ ...5d8f0ee932826bbf7b7c92c4f4dd3f90d33c5.json | 60 ++++++++ ...685ade4a3243eb283607b3763322703a651c2.json | 37 +++++ ...b9d13567a6f1f07fbff1819392a88e675587e.json | 27 ++++ ...d4f7c4eff7d9021f8e2ebfb85622dcf85cc96.json | 43 ++++++ ...acc6aba98e145572720714fb869b5971f917a.json | 19 +++ ...58d05e6df3731ff638d315d58f8110ab96876.json | 15 ++ test/royalties-api.ts | 97 +++++++++++++ test/schema.ts | 15 ++ test/v2-api.ts | 78 +++++++++++ 15 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 test/data/25aa8dfbe47ba945e536c03785dd45e07b6a8297.json create mode 100644 test/data/31641a1006fdc5b9661c49ab5c64fc468c74cc3d.json create mode 100644 test/data/49c5d8f0ee932826bbf7b7c92c4f4dd3f90d33c5.json create mode 100644 test/data/54d685ade4a3243eb283607b3763322703a651c2.json create mode 100644 test/data/8c1b9d13567a6f1f07fbff1819392a88e675587e.json create mode 100644 test/data/b79d4f7c4eff7d9021f8e2ebfb85622dcf85cc96.json create mode 100644 test/data/d99acc6aba98e145572720714fb869b5971f917a.json create mode 100644 test/data/e3758d05e6df3731ff638d315d58f8110ab96876.json create mode 100644 test/royalties-api.ts diff --git a/src/endpoints/market/types.ts b/src/endpoints/market/types.ts index 17a3fab..f64f8d8 100644 --- a/src/endpoints/market/types.ts +++ b/src/endpoints/market/types.ts @@ -14,6 +14,11 @@ import { Marketplace, OfferObject, ResponseStruct, + RoyaltyAccountTotal, + RoyaltyAttributeRule, + RoyaltyConfig, + RoyaltyPayout, + RoyaltyTemplateRule, SaleObject, SalePrice, SalePriceDay, @@ -303,3 +308,28 @@ export class GetStatsGraphResponse extends ResponseStruct { export class GetStatsSalesResponse extends ResponseStruct { @Struct.field(MarketSale) declare data: MarketSale } + +@Struct.type('get_royalty_config_resp') +export class GetRoyaltyConfigResponse extends ResponseStruct { + @Struct.field(RoyaltyConfig) declare data: RoyaltyConfig +} + +@Struct.type('get_royalty_template_rules_resp') +export class GetRoyaltyTemplateRulesResponse extends ResponseStruct { + @Struct.field(RoyaltyTemplateRule, {array: true}) declare data: RoyaltyTemplateRule[] +} + +@Struct.type('get_royalty_attribute_rules_resp') +export class GetRoyaltyAttributeRulesResponse extends ResponseStruct { + @Struct.field(RoyaltyAttributeRule, {array: true}) declare data: RoyaltyAttributeRule[] +} + +@Struct.type('get_royalty_payouts_resp') +export class GetRoyaltyPayoutsResponse extends ResponseStruct { + @Struct.field(RoyaltyPayout, {array: true}) declare data: RoyaltyPayout[] +} + +@Struct.type('get_royalty_account_resp') +export class GetRoyaltyAccountResponse extends ResponseStruct { + @Struct.field(RoyaltyAccountTotal, {array: true}) declare data: RoyaltyAccountTotal[] +} diff --git a/src/endpoints/market/v1.ts b/src/endpoints/market/v1.ts index 200f5c1..25e53e6 100644 --- a/src/endpoints/market/v1.ts +++ b/src/endpoints/market/v1.ts @@ -201,6 +201,26 @@ export interface GetTemplateBuyoffersOptions { sort?: 'created' | 'updated' | 'ending' | 'buyoffer_id' | 'price' | 'template_mint' | 'name' } +export interface GetRoyaltyPayoutsOptions { + recipient?: NameType[] + collection_name?: NameType[] + asset_id?: UInt64Type[] + symbol?: string + listing_type?: 'unresolved' | 'sale' | 'auction' | 'buyoffer' | 'template_buyoffer' + listing_id?: UInt64Type + category?: Array<'founders' | 'template' | 'attribute' | 'dust'> + ids?: UInt64Type[] + // lower_bound and upper_bound range over log_global_sequence. + lower_bound?: string + upper_bound?: string + before?: number + after?: number + page?: number + limit?: number + order?: 'asc' | 'desc' + sort?: 'created' | 'amount' +} + export class MarketV1APIClient { constructor(private client: APIClient) {} @@ -953,4 +973,112 @@ export class MarketV1APIClient { responseType: Market.GetConfigResponse, }) } + + /** + * The royalty configuration of a collection, mirrored from `royaltyconf`. + * + * A collection with no configuration answers HTTP 416, which reaches the + * caller as an `APIError` with `error.response.status === 416`. That is the + * "not configured" answer rather than a failure, and every collection on an + * AtomicMarket v1 chain answers that way. An indexer that predates the + * royalty routes answers 404. + */ + async get_royalty_config(collection_name: NameType) { + return this.client.call({ + path: `/atomicmarket/v1/royalties/${collection_name}`, + method: 'GET', + responseType: Market.GetRoyaltyConfigResponse, + }) + } + + async get_royalty_template_rules( + collection_name: NameType, + options?: { + template_id?: Int32Type[] + page?: number + limit?: number + } + ) { + const bodyParams = buildBodyParams(options) + + return this.client.call({ + path: `/atomicmarket/v1/royalties/${collection_name}/templates`, + method: 'POST', + params: bodyParams, + headers: {'Content-Type': 'application/json'}, + responseType: Market.GetRoyaltyTemplateRulesResponse, + }) + } + + async get_royalty_attribute_rules( + collection_name: NameType, + options?: { + source?: number + field?: string + page?: number + limit?: number + } + ) { + const bodyParams = buildBodyParams(options) + + return this.client.call({ + path: `/atomicmarket/v1/royalties/${collection_name}/attributes`, + method: 'POST', + params: bodyParams, + headers: {'Content-Type': 'application/json'}, + responseType: Market.GetRoyaltyAttributeRulesResponse, + }) + } + + /** The settled payout ledger. Empty on an AtomicMarket v1 chain. */ + async get_royalty_payouts( + options?: GetRoyaltyPayoutsOptions, + extra_options?: {[key: string]: string} + ) { + const bodyParams = buildBodyParams(options, extra_options) + + return this.client.call({ + path: `/atomicmarket/v1/royalties/payouts`, + method: 'POST', + params: bodyParams, + headers: {'Content-Type': 'application/json'}, + responseType: Market.GetRoyaltyPayoutsResponse, + }) + } + + async get_royalty_payouts_count( + options?: GetRoyaltyPayoutsOptions, + extra_options?: {[key: string]: string} + ) { + const bodyParams = buildBodyParams(options, extra_options) + + return this.client.call({ + path: `/atomicmarket/v1/royalties/payouts/_count`, + method: 'POST', + params: bodyParams, + headers: {'Content-Type': 'application/json'}, + responseType: CountResponseStruct, + }) + } + + /** One row per token symbol the account has been paid in. */ + async get_royalty_account( + account: NameType, + options?: { + collection_name?: NameType[] + symbol?: string + before?: number + after?: number + } + ) { + const bodyParams = buildBodyParams(options) + + return this.client.call({ + path: `/atomicmarket/v1/royalties/accounts/${account}`, + method: 'POST', + params: bodyParams, + headers: {'Content-Type': 'application/json'}, + responseType: Market.GetRoyaltyAccountResponse, + }) + } } diff --git a/src/endpoints/types.ts b/src/endpoints/types.ts index ab198c9..c85631c 100644 --- a/src/endpoints/types.ts +++ b/src/endpoints/types.ts @@ -478,6 +478,116 @@ export class Marketplace extends Struct { @Struct.field('string') declare created_at_time: string } +/** + * A royalty recipient and its weight as returned by the API. + * + * This is intentionally not the AtomicMarket contract's `ROYALTYPAIR` struct. + * That one serializes action data for `setroyalconf` and the rule actions, this + * one only decodes an API row, and keeping them apart is what stops a decoded + * row from reaching the wire. + */ +@Struct.type('royalty_pair') +export class RoyaltyPair extends Struct { + @Struct.field(Name) declare recipient: Name + @Struct.field(UInt32) declare weight: UInt32 +} + +/** + * The `royaltyconf` row for a collection, mirrored field for field. + * + * The API answers HTTP 416 for a collection that has no row, and every + * collection on an AtomicMarket v1 chain answers that way. AtomicMarket v2 only. + */ +@Struct.type('royalty_config') +export class RoyaltyConfig extends Struct { + @Struct.field(Name) declare market_contract: Name + @Struct.field(Name) declare collection_name: Name + @Struct.field(RoyaltyPair, {array: true}) declare founders: RoyaltyPair[] + /** How attribute rules combine: 0 merged, 1 granular. */ + @Struct.field(UInt8) declare attribute_mode: UInt8 + @Struct.field(UInt32) declare split_founders: UInt32 + @Struct.field(UInt32) declare split_templates: UInt32 + @Struct.field(UInt32) declare split_attributes: UInt32 + @Struct.field(UInt64) declare updated_at_block: UInt64 + @Struct.field('string') declare updated_at_time: string + @Struct.field(UInt64) declare created_at_block: UInt64 + @Struct.field('string') declare created_at_time: string +} + +/** A `royaltytemp` row: the per-template recipient override. AtomicMarket v2 only. */ +@Struct.type('royalty_template_rule') +export class RoyaltyTemplateRule extends Struct { + @Struct.field(Name) declare market_contract: Name + @Struct.field(Name) declare collection_name: Name + @Struct.field(Int32) declare template_id: Int32 + @Struct.field(RoyaltyPair, {array: true}) declare recipients: RoyaltyPair[] + @Struct.field(UInt64) declare updated_at_block: UInt64 + @Struct.field('string') declare updated_at_time: string + @Struct.field(UInt64) declare created_at_block: UInt64 + @Struct.field('string') declare created_at_time: string +} + +/** A `royaltyattr` row: the attribute-matched recipient override. AtomicMarket v2 only. */ +@Struct.type('royalty_attribute_rule') +export class RoyaltyAttributeRule extends Struct { + @Struct.field(Name) declare market_contract: Name + @Struct.field(Name) declare collection_name: Name + @Struct.field(UInt64) declare rule_id: UInt64 + /** Which data source the matched attribute is read from, as the contract stores it. */ + @Struct.field(UInt8) declare source: UInt8 + @Struct.field('string') declare field: string + /** + * The raw `["type", value]` variant tuple the rule matches on. It is kept + * untyped so an integer payload stays the string the API sent and nothing + * is reparsed. The content is chain-authored by the collection and relayed + * by the indexer, so a caller shape-checks it before use. + */ + @Struct.field('any') declare value: [string, unknown] + @Struct.field(UInt32) declare weight: UInt32 + @Struct.field(RoyaltyPair, {array: true}) declare recipients: RoyaltyPair[] + /** Hex-encoded sha256 of the matched attribute, as the contract stores it. */ + @Struct.field('string') declare lookup_hash: string + @Struct.field(UInt64) declare updated_at_block: UInt64 + @Struct.field('string') declare updated_at_time: string + @Struct.field(UInt64) declare created_at_block: UInt64 + @Struct.field('string') declare created_at_time: string +} + +/** + * One settled royalty payout, keyed by the log trace global sequence and the + * entry's position in the payouts vector. AtomicMarket v2 only. + */ +@Struct.type('royalty_payout') +export class RoyaltyPayout extends Struct { + @Struct.field(Name) declare market_contract: Name + @Struct.field(UInt64) declare log_global_sequence: UInt64 + @Struct.field(UInt32) declare payout_index: UInt32 + /** One of unresolved, sale, auction, buyoffer, template_buyoffer. */ + @Struct.field('string', {optional: true}) declare listing_type: string + @Struct.field(UInt64, {optional: true}) declare listing_id: UInt64 + /** One of founders, template, attribute, dust. */ + @Struct.field('string', {optional: true}) declare category: string + @Struct.field(Name) declare collection_name: Name + @Struct.field(UInt64, {optional: true}) declare asset_id: UInt64 + @Struct.field(Int32, {optional: true}) declare template_id: Int32 + @Struct.field(UInt64, {optional: true}) declare rule_id: UInt64 + @Struct.field(Name) declare recipient: Name + @Struct.field(UInt64) declare amount: UInt64 + @Struct.field('string') declare token_symbol: string + @Struct.field(UInt8) declare token_precision: UInt8 + @Struct.field(Name) declare token_contract: Name + /** Hex-encoded id of the transaction the payout was logged in. */ + @Struct.field('string') declare txid: string + @Struct.field(UInt64) declare created_at_block: UInt64 + @Struct.field('string') declare created_at_time: string +} + +/** An account's settled royalties for one token symbol. AtomicMarket v2 only. */ +@Struct.type('royalty_account_total') +export class RoyaltyAccountTotal extends TokenAmount { + @Struct.field(UInt64) declare payout_count: UInt64 +} + @Struct.type('saleprice') export class SalePrice extends Struct { @Struct.field(UInt64, {optional: true}) declare sale_id: UInt64 diff --git a/test/config.ts b/test/config.ts index b75eb08..a118e53 100644 --- a/test/config.ts +++ b/test/config.ts @@ -2,11 +2,16 @@ export const TEST_CONFIG = { // Base URL for the AtomicAssets API BASE_URL: 'https://wax-atomic.alcor.exchange/', - + + // Base URL for an AtomicMarket v2 indexer. The host above indexes WAX + // mainnet, an AtomicMarket v1 chain, where every royalty route answers 416 + // or an empty array, so the royalty fixtures are recorded here instead. + V2_BASE_URL: 'https://test.wax.api.atomicassets.io', + // Default test timeout and slow thresholds TIMEOUT: 10 * 1000, SLOW_THRESHOLD: 300, } as const // Export individual values for convenience -export const { BASE_URL, TIMEOUT, SLOW_THRESHOLD } = TEST_CONFIG +export const { BASE_URL, V2_BASE_URL, TIMEOUT, SLOW_THRESHOLD } = TEST_CONFIG diff --git a/test/data/25aa8dfbe47ba945e536c03785dd45e07b6a8297.json b/test/data/25aa8dfbe47ba945e536c03785dd45e07b6a8297.json new file mode 100644 index 0000000..a880b83 --- /dev/null +++ b/test/data/25aa8dfbe47ba945e536c03785dd45e07b6a8297.json @@ -0,0 +1,35 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/royaltycol11/templates", + "params": { + "method": "POST", + "body": "{}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": [ + { + "market_contract": "atomicmarket", + "collection_name": "royaltycol11", + "template_id": "662912", + "recipients": [ + { + "weight": 1, + "recipient": "pe2etestacct" + } + ], + "updated_at_block": "414895353", + "updated_at_time": "1783371584000", + "created_at_block": "414895353", + "created_at_time": "1783371584000" + } + ], + "query_time": 1787002594984 + }, + "text": "{\"success\":true,\"data\":[{\"market_contract\":\"atomicmarket\",\"collection_name\":\"royaltycol11\",\"template_id\":\"662912\",\"recipients\":[{\"weight\":1,\"recipient\":\"pe2etestacct\"}],\"updated_at_block\":\"414895353\",\"updated_at_time\":\"1783371584000\",\"created_at_block\":\"414895353\",\"created_at_time\":\"1783371584000\"}],\"query_time\":1787002594984}" +} \ No newline at end of file diff --git a/test/data/31641a1006fdc5b9661c49ab5c64fc468c74cc3d.json b/test/data/31641a1006fdc5b9661c49ab5c64fc468c74cc3d.json new file mode 100644 index 0000000..769ec7d --- /dev/null +++ b/test/data/31641a1006fdc5b9661c49ab5c64fc468c74cc3d.json @@ -0,0 +1,60 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/payouts", + "params": { + "method": "POST", + "body": "{\"limit\":\"2\"}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": [ + { + "market_contract": "atomicmarket", + "log_global_sequence": "840330124", + "payout_index": 0, + "listing_type": "sale", + "listing_id": "46890", + "category": "attribute", + "collection_name": "royaltycol11", + "asset_id": "1099603751717", + "template_id": null, + "rule_id": "2", + "recipient": "jacktestr125", + "amount": "1250000", + "token_symbol": "WAX", + "token_precision": 8, + "token_contract": "eosio.token", + "txid": "6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a", + "created_at_block": "414927681", + "created_at_time": "1783387748000" + }, + { + "market_contract": "atomicmarket", + "log_global_sequence": "840330123", + "payout_index": 0, + "listing_type": "sale", + "listing_id": "46890", + "category": "template", + "collection_name": "royaltycol11", + "asset_id": "1099603751717", + "template_id": "662912", + "rule_id": null, + "recipient": "pe2etestacct", + "amount": "1250000", + "token_symbol": "WAX", + "token_precision": 8, + "token_contract": "eosio.token", + "txid": "6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a", + "created_at_block": "414927681", + "created_at_time": "1783387748000" + } + ], + "query_time": 1787002593620 + }, + "text": "{\"success\":true,\"data\":[{\"market_contract\":\"atomicmarket\",\"log_global_sequence\":\"840330124\",\"payout_index\":0,\"listing_type\":\"sale\",\"listing_id\":\"46890\",\"category\":\"attribute\",\"collection_name\":\"royaltycol11\",\"asset_id\":\"1099603751717\",\"template_id\":null,\"rule_id\":\"2\",\"recipient\":\"jacktestr125\",\"amount\":\"1250000\",\"token_symbol\":\"WAX\",\"token_precision\":8,\"token_contract\":\"eosio.token\",\"txid\":\"6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a\",\"created_at_block\":\"414927681\",\"created_at_time\":\"1783387748000\"},{\"market_contract\":\"atomicmarket\",\"log_global_sequence\":\"840330123\",\"payout_index\":0,\"listing_type\":\"sale\",\"listing_id\":\"46890\",\"category\":\"template\",\"collection_name\":\"royaltycol11\",\"asset_id\":\"1099603751717\",\"template_id\":\"662912\",\"rule_id\":null,\"recipient\":\"pe2etestacct\",\"amount\":\"1250000\",\"token_symbol\":\"WAX\",\"token_precision\":8,\"token_contract\":\"eosio.token\",\"txid\":\"6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a\",\"created_at_block\":\"414927681\",\"created_at_time\":\"1783387748000\"}],\"query_time\":1787002593620}" +} \ No newline at end of file diff --git a/test/data/49c5d8f0ee932826bbf7b7c92c4f4dd3f90d33c5.json b/test/data/49c5d8f0ee932826bbf7b7c92c4f4dd3f90d33c5.json new file mode 100644 index 0000000..1a621a0 --- /dev/null +++ b/test/data/49c5d8f0ee932826bbf7b7c92c4f4dd3f90d33c5.json @@ -0,0 +1,60 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/payouts", + "params": { + "method": "POST", + "body": "{\"category\":\"attribute\",\"listing_type\":\"sale\",\"sort\":\"amount\",\"order\":\"asc\",\"limit\":\"2\"}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": [ + { + "market_contract": "atomicmarket", + "log_global_sequence": "840330124", + "payout_index": 0, + "listing_type": "sale", + "listing_id": "46890", + "category": "attribute", + "collection_name": "royaltycol11", + "asset_id": "1099603751717", + "template_id": null, + "rule_id": "2", + "recipient": "jacktestr125", + "amount": "1250000", + "token_symbol": "WAX", + "token_precision": 8, + "token_contract": "eosio.token", + "txid": "6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a", + "created_at_block": "414927681", + "created_at_time": "1783387748000" + }, + { + "market_contract": "atomicmarket", + "log_global_sequence": "840242817", + "payout_index": 0, + "listing_type": "sale", + "listing_id": "46855", + "category": "attribute", + "collection_name": "royaltycol11", + "asset_id": "1099603751714", + "template_id": null, + "rule_id": "1", + "recipient": "jacktestr125", + "amount": "12500000", + "token_symbol": "WAX", + "token_precision": 8, + "token_contract": "eosio.token", + "txid": "a346ac1e0324c39a677f14dd85f23f6f94d602bf229d6810050ac3512a395a08", + "created_at_block": "414849702", + "created_at_time": "1783348758500" + } + ], + "query_time": 1787002593925 + }, + "text": "{\"success\":true,\"data\":[{\"market_contract\":\"atomicmarket\",\"log_global_sequence\":\"840330124\",\"payout_index\":0,\"listing_type\":\"sale\",\"listing_id\":\"46890\",\"category\":\"attribute\",\"collection_name\":\"royaltycol11\",\"asset_id\":\"1099603751717\",\"template_id\":null,\"rule_id\":\"2\",\"recipient\":\"jacktestr125\",\"amount\":\"1250000\",\"token_symbol\":\"WAX\",\"token_precision\":8,\"token_contract\":\"eosio.token\",\"txid\":\"6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a\",\"created_at_block\":\"414927681\",\"created_at_time\":\"1783387748000\"},{\"market_contract\":\"atomicmarket\",\"log_global_sequence\":\"840242817\",\"payout_index\":0,\"listing_type\":\"sale\",\"listing_id\":\"46855\",\"category\":\"attribute\",\"collection_name\":\"royaltycol11\",\"asset_id\":\"1099603751714\",\"template_id\":null,\"rule_id\":\"1\",\"recipient\":\"jacktestr125\",\"amount\":\"12500000\",\"token_symbol\":\"WAX\",\"token_precision\":8,\"token_contract\":\"eosio.token\",\"txid\":\"a346ac1e0324c39a677f14dd85f23f6f94d602bf229d6810050ac3512a395a08\",\"created_at_block\":\"414849702\",\"created_at_time\":\"1783348758500\"}],\"query_time\":1787002593925}" +} \ No newline at end of file diff --git a/test/data/54d685ade4a3243eb283607b3763322703a651c2.json b/test/data/54d685ade4a3243eb283607b3763322703a651c2.json new file mode 100644 index 0000000..8ad46f2 --- /dev/null +++ b/test/data/54d685ade4a3243eb283607b3763322703a651c2.json @@ -0,0 +1,37 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/royaltycol11", + "params": { + "method": "GET", + "headers": {} + } + }, + "status": 200, + "json": { + "success": true, + "data": { + "market_contract": "atomicmarket", + "collection_name": "royaltycol11", + "founders": [ + { + "weight": 1, + "recipient": "jacktestr125" + }, + { + "weight": 3, + "recipient": "pe2etestacct" + } + ], + "attribute_mode": 0, + "split_founders": "2", + "split_templates": "1", + "split_attributes": "1", + "updated_at_block": "414895352", + "updated_at_time": "1783371583500", + "created_at_block": "414895352", + "created_at_time": "1783371583500" + }, + "query_time": 1787002594513 + }, + "text": "{\"success\":true,\"data\":{\"market_contract\":\"atomicmarket\",\"collection_name\":\"royaltycol11\",\"founders\":[{\"weight\":1,\"recipient\":\"jacktestr125\"},{\"weight\":3,\"recipient\":\"pe2etestacct\"}],\"attribute_mode\":0,\"split_founders\":\"2\",\"split_templates\":\"1\",\"split_attributes\":\"1\",\"updated_at_block\":\"414895352\",\"updated_at_time\":\"1783371583500\",\"created_at_block\":\"414895352\",\"created_at_time\":\"1783371583500\"},\"query_time\":1787002594513}" +} \ No newline at end of file diff --git a/test/data/8c1b9d13567a6f1f07fbff1819392a88e675587e.json b/test/data/8c1b9d13567a6f1f07fbff1819392a88e675587e.json new file mode 100644 index 0000000..80ea5ba --- /dev/null +++ b/test/data/8c1b9d13567a6f1f07fbff1819392a88e675587e.json @@ -0,0 +1,27 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/accounts/jacktestr125", + "params": { + "method": "POST", + "body": "{}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": [ + { + "token_symbol": "WAX", + "token_precision": 8, + "token_contract": "eosio.token", + "amount": "76875000", + "payout_count": "10" + } + ], + "query_time": 1787002594271 + }, + "text": "{\"success\":true,\"data\":[{\"token_symbol\":\"WAX\",\"token_precision\":8,\"token_contract\":\"eosio.token\",\"amount\":\"76875000\",\"payout_count\":\"10\"}],\"query_time\":1787002594271}" +} \ No newline at end of file diff --git a/test/data/b79d4f7c4eff7d9021f8e2ebfb85622dcf85cc96.json b/test/data/b79d4f7c4eff7d9021f8e2ebfb85622dcf85cc96.json new file mode 100644 index 0000000..5177ba8 --- /dev/null +++ b/test/data/b79d4f7c4eff7d9021f8e2ebfb85622dcf85cc96.json @@ -0,0 +1,43 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/royaltycol11/attributes", + "params": { + "method": "POST", + "body": "{}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": [ + { + "market_contract": "atomicmarket", + "collection_name": "royaltycol11", + "rule_id": "2", + "source": 0, + "field": "rarity", + "value": [ + "string", + "legendary" + ], + "weight": "1", + "recipients": [ + { + "weight": 1, + "recipient": "jacktestr125" + } + ], + "lookup_hash": "68ec3427c453d24cfd9faaa1db9d39533ab06b8127bb09a6f2958e5c26ebce74", + "updated_at_block": "414895354", + "updated_at_time": "1783371584500", + "created_at_block": "414895354", + "created_at_time": "1783371584500" + } + ], + "query_time": 1787002595151 + }, + "text": "{\"success\":true,\"data\":[{\"market_contract\":\"atomicmarket\",\"collection_name\":\"royaltycol11\",\"rule_id\":\"2\",\"source\":0,\"field\":\"rarity\",\"value\":[\"string\",\"legendary\"],\"weight\":\"1\",\"recipients\":[{\"weight\":1,\"recipient\":\"jacktestr125\"}],\"lookup_hash\":\"68ec3427c453d24cfd9faaa1db9d39533ab06b8127bb09a6f2958e5c26ebce74\",\"updated_at_block\":\"414895354\",\"updated_at_time\":\"1783371584500\",\"created_at_block\":\"414895354\",\"created_at_time\":\"1783371584500\"}],\"query_time\":1787002595151}" +} \ No newline at end of file diff --git a/test/data/d99acc6aba98e145572720714fb869b5971f917a.json b/test/data/d99acc6aba98e145572720714fb869b5971f917a.json new file mode 100644 index 0000000..0e9b925 --- /dev/null +++ b/test/data/d99acc6aba98e145572720714fb869b5971f917a.json @@ -0,0 +1,19 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/payouts/_count", + "params": { + "method": "POST", + "body": "{}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": "20", + "query_time": 1787002594102 + }, + "text": "{\"success\":true,\"data\":\"20\",\"query_time\":1787002594102}" +} \ No newline at end of file diff --git a/test/data/e3758d05e6df3731ff638d315d58f8110ab96876.json b/test/data/e3758d05e6df3731ff638d315d58f8110ab96876.json new file mode 100644 index 0000000..6e4b39d --- /dev/null +++ b/test/data/e3758d05e6df3731ff638d315d58f8110ab96876.json @@ -0,0 +1,15 @@ +{ + "request": { + "path": "https://test.wax.api.atomicassets.io/atomicmarket/v1/royalties/alien.worlds", + "params": { + "method": "GET", + "headers": {} + } + }, + "status": 416, + "json": { + "success": false, + "message": "Royalty config not found" + }, + "text": "{\"success\":false,\"message\":\"Royalty config not found\"}" +} \ No newline at end of file diff --git a/test/royalties-api.ts b/test/royalties-api.ts new file mode 100644 index 0000000..6d81718 --- /dev/null +++ b/test/royalties-api.ts @@ -0,0 +1,97 @@ +import {assert} from 'chai' + +import {APIClient, APIError, FetchProvider} from '@wharfkit/antelope' +import {mockFetch} from '@wharfkit/mock-data' +import {V2_BASE_URL, TIMEOUT, SLOW_THRESHOLD} from './config' + +import {AtomicAssetsAPIClient, Types} from '$lib' + +// The royalty routes answer with rows only on an AtomicMarket v2 chain, so this +// client points at the testnet indexer instead of the shared BASE_URL. +const client = new APIClient({ + provider: new FetchProvider(V2_BASE_URL, {fetch: mockFetch}), +}) + +// Setup the API +const atomicassets = new AtomicAssetsAPIClient(client) + +const collectionName = 'royaltycol11' +const unconfiguredCollection = 'alien.worlds' +const recipient = 'jacktestr125' + +suite('atomicmarket royalties', function () { + this.slow(SLOW_THRESHOLD) + this.timeout(TIMEOUT) + + test('get_royalty_payouts', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_payouts({limit: 2}) + assert.instanceOf(res, Types.Market.GetRoyaltyPayoutsResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data) + }) + + test('get_royalty_payouts filtered', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_payouts({ + category: ['attribute'], + listing_type: 'sale', + sort: 'amount', + order: 'asc', + limit: 2, + }) + assert.instanceOf(res, Types.Market.GetRoyaltyPayoutsResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data) + }) + + test('get_royalty_payouts_count', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_payouts_count() + assert.instanceOf(res, Types.CountResponseStruct) + assert.equal(res.success, true) + assert.isAbove(res.data.toNumber(), 0) + }) + + test('get_royalty_account', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_account(recipient) + assert.instanceOf(res, Types.Market.GetRoyaltyAccountResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data) + assert.isAbove(res.data[0].payout_count.toNumber(), 0) + }) + + test('get_royalty_config', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_config(collectionName) + assert.instanceOf(res, Types.Market.GetRoyaltyConfigResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data) + assert.isTrue(res.data.collection_name.equals(collectionName)) + }) + + test('get_royalty_config answers 416 for an unconfigured collection', async function () { + // A collection with no royalty configuration is not a failure for the + // caller, and on an AtomicMarket v1 chain every collection answers so. + let error: APIError | undefined + + try { + await atomicassets.atomicmarket.v1.get_royalty_config(unconfiguredCollection) + } catch (caught) { + error = caught as APIError + } + + assert.instanceOf(error, APIError) + assert.equal(error?.response.status, 416) + }) + + test('get_royalty_template_rules', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_template_rules(collectionName) + assert.instanceOf(res, Types.Market.GetRoyaltyTemplateRulesResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data) + }) + + test('get_royalty_attribute_rules', async function () { + const res = await atomicassets.atomicmarket.v1.get_royalty_attribute_rules(collectionName) + assert.instanceOf(res, Types.Market.GetRoyaltyAttributeRulesResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data) + }) +}) diff --git a/test/schema.ts b/test/schema.ts index 346ac50..b5bc81e 100644 --- a/test/schema.ts +++ b/test/schema.ts @@ -102,6 +102,21 @@ suite('Schema', function () { assert.equal(schema.types[0].mediatype, 'video/mp4') assert.equal(schema.types[0].info, 'trailer') }) + + test('the API royalty pair stays separate from the contract royalty pair', function () { + // The contract's ROYALTYPAIR is what setroyalconf and the rule actions + // serialize. The API struct only decodes a response row, and keeping the + // two apart is what stops a decoded row from reaching action data. + const contractFields = AtomicMarketContract.Types.ROYALTYPAIR.abiFields?.map((f) => f.name) + const apiFields = Types.RoyaltyPair.abiFields?.map((f) => f.name) + + assert.deepEqual(contractFields, ['recipient', 'weight']) + assert.deepEqual(apiFields, ['recipient', 'weight']) + assert.notEqual(Types.RoyaltyPair, AtomicMarketContract.Types.ROYALTYPAIR as any) + assert.equal(AtomicMarketContract.Types.ROYALTYPAIR.abiName, 'ROYALTYPAIR') + assert.equal(Types.RoyaltyPair.abiName, 'royalty_pair') + }) + test('the API format type stays separate from the contract format type', function () { // The contract's FORMAT is what createschema and extendschema serialize, // so it must stay at {name, type}. The API reports two further fields. diff --git a/test/v2-api.ts b/test/v2-api.ts index ee138bb..e9e65bd 100644 --- a/test/v2-api.ts +++ b/test/v2-api.ts @@ -218,4 +218,82 @@ suite('v2 API response fields', function () { // sends for a schema that has none. assert.isUndefined(schema.types) }) + + test('a royalty payout decodes the nullable ids the indexer emits', function () { + const attributePayout = Types.RoyaltyPayout.from({ + market_contract: 'atomicmarket', + log_global_sequence: '840330124', + payout_index: 0, + listing_type: 'sale', + listing_id: '46890', + category: 'attribute', + collection_name: 'royaltycol11', + asset_id: '1099603751717', + template_id: null, + rule_id: '2', + recipient: 'jacktestr125', + amount: '1250000', + token_symbol: 'WAX', + token_precision: 8, + token_contract: 'eosio.token', + txid: '6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a', + created_at_block: '414927681', + created_at_time: '1783387748000', + }) + + assert.equal(attributePayout.amount.toNumber(), 1250000) + assert.equal(attributePayout.listing_id.toNumber(), 46890) + assert.equal(attributePayout.rule_id.toNumber(), 2) + assert.isUndefined(attributePayout.template_id) + + // listing_type and category are the strings the indexer maps the stored + // integers to, and it sends null for a value it has no name for. + const unmappedPayout = Types.RoyaltyPayout.from({ + market_contract: 'atomicmarket', + log_global_sequence: '840330125', + payout_index: 1, + listing_type: null, + listing_id: null, + category: null, + collection_name: 'royaltycol11', + asset_id: null, + template_id: null, + rule_id: null, + recipient: 'jacktestr125', + amount: '1250000', + token_symbol: 'WAX', + token_precision: 8, + token_contract: 'eosio.token', + txid: '6df6118add83bab9b0d79b5a7a6cf3877133893daefb96280f6f52943db74f2a', + created_at_block: '414927681', + created_at_time: '1783387748000', + }) + + assert.isNull(unmappedPayout.listing_type) + assert.isNull(unmappedPayout.category) + assert.isUndefined(unmappedPayout.listing_id) + assert.isUndefined(unmappedPayout.asset_id) + }) + + test('a royalty attribute rule keeps the raw variant tuple', function () { + const rule = Types.RoyaltyAttributeRule.from({ + market_contract: 'atomicmarket', + collection_name: 'royaltycol11', + rule_id: '2', + source: 0, + field: 'rarity', + value: ['string', 'legendary'], + weight: '1', + recipients: [{recipient: 'jacktestr125', weight: 1}], + lookup_hash: '68ec3427c453d24cfd9faaa1db9d39533ab06b8127bb09a6f2958e5c26ebce74', + updated_at_block: '414895354', + updated_at_time: '1783371584500', + created_at_block: '414895354', + created_at_time: '1783371584500', + }) + + assert.deepEqual(rule.value, ['string', 'legendary']) + assert.equal(rule.weight.toNumber(), 1) + assert.isTrue(rule.recipients[0].recipient.equals('jacktestr125')) + }) }) From 8bcb65a51038b5b64fcd7c6d0157e987d8575092 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:58:01 -0400 Subject: [PATCH 4/5] Add the marketplace stats endpoint --- src/endpoints/market/types.ts | 31 +++ src/endpoints/market/v1.ts | 24 +++ test/api.ts | 15 ++ ...bd9169724d91c76934cae7a95530d2ffc4bf9.json | 187 ++++++++++++++++++ test/v2-api.ts | 31 +++ 5 files changed, 288 insertions(+) create mode 100644 test/data/81dbd9169724d91c76934cae7a95530d2ffc4bf9.json diff --git a/src/endpoints/market/types.ts b/src/endpoints/market/types.ts index f64f8d8..b8e0f73 100644 --- a/src/endpoints/market/types.ts +++ b/src/endpoints/market/types.ts @@ -108,6 +108,32 @@ export class MarketAccount extends Struct { @Struct.field(AccountStat) declare result: AccountStat } +@Struct.type('marketplace_stat') +export class MarketplaceStat extends Struct { + @Struct.field(Name) declare market_contract: Name + /** + * Null when no marketplace is stored, and the empty string when the + * stored value is the default marketplace name. Both mean the same + * thing to a client. + */ + @Struct.field('string', {optional: true}) declare marketplace_name: string + @Struct.field(UInt64) declare sellers: UInt64 + @Struct.field(UInt64) declare buyers: UInt64 + /** + * The field is declared optional defensively because the outer sort + * carries NULLS LAST. The aggregate answers `0`, not null, for a side + * with no rows. + */ + @Struct.field(UInt64, {optional: true}) declare maker_volume: UInt64 + @Struct.field(UInt64, {optional: true}) declare taker_volume: UInt64 +} + +@Struct.type('market_marketplaces') +export class MarketMarketplaces extends Struct { + @Struct.field(Token) declare symbol: Token + @Struct.field(MarketplaceStat, {array: true}) declare results: MarketplaceStat[] +} + @Struct.type('schema_stat_v1') export class SchemaStatV1 extends Struct { @Struct.field(Name) declare contract: Name @@ -309,6 +335,11 @@ export class GetStatsSalesResponse extends ResponseStruct { @Struct.field(MarketSale) declare data: MarketSale } +@Struct.type('get_stats_markets_resp') +export class GetStatsMarketsResponse extends ResponseStruct { + @Struct.field(MarketMarketplaces) declare data: MarketMarketplaces +} + @Struct.type('get_royalty_config_resp') export class GetRoyaltyConfigResponse extends ResponseStruct { @Struct.field(RoyaltyConfig) declare data: RoyaltyConfig diff --git a/src/endpoints/market/v1.ts b/src/endpoints/market/v1.ts index 25e53e6..fbc7f96 100644 --- a/src/endpoints/market/v1.ts +++ b/src/endpoints/market/v1.ts @@ -966,6 +966,30 @@ export class MarketV1APIClient { }) } + /** + * Seller, buyer, and volume totals per marketplace for one token symbol. + * + * Bound the query with `collection_whitelist` or an `after` window. On a + * mainnet-sized indexer the unbounded aggregate exceeds the server timeout. + */ + async get_stats_markets(options: { + symbol: string + collection_blacklist?: NameType[] + collection_whitelist?: NameType[] + before?: number + after?: number + }) { + const bodyParams = buildBodyParams(options) + + return this.client.call({ + path: `/atomicmarket/v1/stats/markets`, + method: 'POST', + params: bodyParams, + headers: {'Content-Type': 'application/json'}, + responseType: Market.GetStatsMarketsResponse, + }) + } + async get_config() { return this.client.call({ path: '/atomicmarket/v1/config', diff --git a/test/api.ts b/test/api.ts index 5c63ea8..69e528e 100644 --- a/test/api.ts +++ b/test/api.ts @@ -779,6 +779,21 @@ suite('atomicmarket', function () { assert.isNotEmpty(res.data) }) + test('get_stats_markets', async function () { + // The aggregate runs over every sale the indexer holds, so an unbounded + // call answers 408 on a mainnet-sized host. One collection and an after + // window keep it inside the server timeout. + const res = await atomicassets.atomicmarket.v1.get_stats_markets({ + symbol: 'WAX', + collection_whitelist: ['alien.worlds'], + after: 1750000000000, + }) + assert.instanceOf(res, Types.Market.GetStatsMarketsResponse) + assert.equal(res.success, true) + assert.isNotEmpty(res.data.symbol) + assert.isNotEmpty(res.data.results) + }) + test('get_config', async function () { const res = await atomicassets.atomicmarket.v1.get_config() assert.instanceOf(res, Types.Market.GetConfigResponse) diff --git a/test/data/81dbd9169724d91c76934cae7a95530d2ffc4bf9.json b/test/data/81dbd9169724d91c76934cae7a95530d2ffc4bf9.json new file mode 100644 index 0000000..c152541 --- /dev/null +++ b/test/data/81dbd9169724d91c76934cae7a95530d2ffc4bf9.json @@ -0,0 +1,187 @@ +{ + "request": { + "path": "https://wax-atomic.alcor.exchange/atomicmarket/v1/stats/markets", + "params": { + "method": "POST", + "body": "{\"symbol\":\"WAX\",\"collection_whitelist\":\"alien.worlds\",\"after\":\"1750000000000\"}", + "headers": { + "Content-Type": "application/json" + } + } + }, + "status": 200, + "json": { + "success": true, + "data": { + "symbol": { + "token_symbol": "WAX", + "token_contract": "eosio.token", + "token_precision": 8 + }, + "results": [ + { + "market_contract": "atomicmarket", + "marketplace_name": null, + "sellers": "4896", + "buyers": "0", + "maker_volume": "1749594747693104", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "", + "sellers": "0", + "buyers": "2106", + "maker_volume": "0", + "taker_volume": "1412670696427700" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "nft.hive", + "sellers": "549", + "buyers": "1082", + "maker_volume": "406043235255205", + "taker_volume": "553583840630693" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "market.nefty", + "sellers": "452", + "buyers": "395", + "maker_volume": "90515525429321", + "taker_volume": "269335609220115" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "f12keymarket", + "sellers": "9", + "buyers": "53", + "maker_volume": "541643330848", + "taker_volume": "9862644226728" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "atomicsniper", + "sellers": "4", + "buyers": "14", + "maker_volume": "2934474234377", + "taker_volume": "2933355536600" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "market.nemo", + "sellers": "0", + "buyers": "1", + "maker_volume": "0", + "taker_volume": "5470000000000" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "bazaarbazaar", + "sellers": "6", + "buyers": "1", + "maker_volume": "3189822290372", + "taker_volume": "8131096465" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "market.wax", + "sellers": "44", + "buyers": "0", + "maker_volume": "555352208064", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "market.place", + "sellers": "1", + "buyers": "0", + "maker_volume": "300000000000", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "waxplorercom", + "sellers": "23", + "buyers": "0", + "maker_volume": "244857289370", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "earncashback", + "sellers": "0", + "buyers": "1", + "maker_volume": "0", + "taker_volume": "190773056817" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "alcor", + "sellers": "2", + "buyers": "0", + "maker_volume": "111153500000", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "waxportal", + "sellers": "47", + "buyers": "1", + "maker_volume": "108116880000", + "taker_volume": "38000000" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "hoardiostore", + "sellers": "0", + "buyers": "1", + "maker_volume": "0", + "taker_volume": "75957262807" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "kipsmarket11", + "sellers": "1", + "buyers": "1", + "maker_volume": "992228134", + "taker_volume": "12700255878" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "wax.stash", + "sellers": "5", + "buyers": "0", + "maker_volume": "3378000000", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "chainchampss", + "sellers": "1", + "buyers": "0", + "maker_volume": "424710000", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "wombatmarket", + "sellers": "2", + "buyers": "0", + "maker_volume": "29200000", + "taker_volume": "0" + }, + { + "market_contract": "atomicmarket", + "marketplace_name": "rwax", + "sellers": "0", + "buyers": "1", + "maker_volume": "0", + "taker_volume": "6534992" + } + ] + }, + "query_time": 1787002602663 + }, + "text": "{\"success\":true,\"data\":{\"symbol\":{\"token_symbol\":\"WAX\",\"token_contract\":\"eosio.token\",\"token_precision\":8},\"results\":[{\"market_contract\":\"atomicmarket\",\"marketplace_name\":null,\"sellers\":\"4896\",\"buyers\":\"0\",\"maker_volume\":\"1749594747693104\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"\",\"sellers\":\"0\",\"buyers\":\"2106\",\"maker_volume\":\"0\",\"taker_volume\":\"1412670696427700\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"nft.hive\",\"sellers\":\"549\",\"buyers\":\"1082\",\"maker_volume\":\"406043235255205\",\"taker_volume\":\"553583840630693\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"market.nefty\",\"sellers\":\"452\",\"buyers\":\"395\",\"maker_volume\":\"90515525429321\",\"taker_volume\":\"269335609220115\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"f12keymarket\",\"sellers\":\"9\",\"buyers\":\"53\",\"maker_volume\":\"541643330848\",\"taker_volume\":\"9862644226728\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"atomicsniper\",\"sellers\":\"4\",\"buyers\":\"14\",\"maker_volume\":\"2934474234377\",\"taker_volume\":\"2933355536600\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"market.nemo\",\"sellers\":\"0\",\"buyers\":\"1\",\"maker_volume\":\"0\",\"taker_volume\":\"5470000000000\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"bazaarbazaar\",\"sellers\":\"6\",\"buyers\":\"1\",\"maker_volume\":\"3189822290372\",\"taker_volume\":\"8131096465\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"market.wax\",\"sellers\":\"44\",\"buyers\":\"0\",\"maker_volume\":\"555352208064\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"market.place\",\"sellers\":\"1\",\"buyers\":\"0\",\"maker_volume\":\"300000000000\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"waxplorercom\",\"sellers\":\"23\",\"buyers\":\"0\",\"maker_volume\":\"244857289370\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"earncashback\",\"sellers\":\"0\",\"buyers\":\"1\",\"maker_volume\":\"0\",\"taker_volume\":\"190773056817\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"alcor\",\"sellers\":\"2\",\"buyers\":\"0\",\"maker_volume\":\"111153500000\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"waxportal\",\"sellers\":\"47\",\"buyers\":\"1\",\"maker_volume\":\"108116880000\",\"taker_volume\":\"38000000\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"hoardiostore\",\"sellers\":\"0\",\"buyers\":\"1\",\"maker_volume\":\"0\",\"taker_volume\":\"75957262807\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"kipsmarket11\",\"sellers\":\"1\",\"buyers\":\"1\",\"maker_volume\":\"992228134\",\"taker_volume\":\"12700255878\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"wax.stash\",\"sellers\":\"5\",\"buyers\":\"0\",\"maker_volume\":\"3378000000\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"chainchampss\",\"sellers\":\"1\",\"buyers\":\"0\",\"maker_volume\":\"424710000\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"wombatmarket\",\"sellers\":\"2\",\"buyers\":\"0\",\"maker_volume\":\"29200000\",\"taker_volume\":\"0\"},{\"market_contract\":\"atomicmarket\",\"marketplace_name\":\"rwax\",\"sellers\":\"0\",\"buyers\":\"1\",\"maker_volume\":\"0\",\"taker_volume\":\"6534992\"}]},\"query_time\":1787002602663}" +} \ No newline at end of file diff --git a/test/v2-api.ts b/test/v2-api.ts index e9e65bd..13edc78 100644 --- a/test/v2-api.ts +++ b/test/v2-api.ts @@ -296,4 +296,35 @@ suite('v2 API response fields', function () { assert.equal(rule.weight.toNumber(), 1) assert.isTrue(rule.recipients[0].recipient.equals('jacktestr125')) }) + + test('a marketplace stat decodes an unnamed marketplace and empty volumes', function () { + const unnamed = Types.Market.MarketplaceStat.from({ + market_contract: 'atomicmarket', + marketplace_name: null, + sellers: '4896', + buyers: '0', + maker_volume: '1749594747693104', + taker_volume: null, + }) + + // Null when no marketplace is stored, and the empty string when the + // stored value is the default marketplace name. Both mean the same + // thing to a client. + const blank = Types.Market.MarketplaceStat.from({ + market_contract: 'atomicmarket', + marketplace_name: '', + sellers: '0', + buyers: '2106', + maker_volume: null, + taker_volume: '1412670696427700', + }) + + assert.isNull(unnamed.marketplace_name) + assert.equal(unnamed.sellers.toNumber(), 4896) + assert.isUndefined(unnamed.taker_volume) + + assert.equal(blank.marketplace_name, '') + assert.isUndefined(blank.maker_volume) + assert.equal(blank.taker_volume.toNumber(), 1412670696427700) + }) }) From 385c3d012141aeaaa375bc87c208572373c5a2cf Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:58:02 -0400 Subject: [PATCH 5/5] Document AtomicAssets v2 and AtomicMarket v2 in the README --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/README.md b/README.md index 6dcdaaf..ed67b30 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,79 @@ AtomicAssets library for Wharf yarn add @wharfkit/atomicassets ``` +## AtomicAssets v2 and AtomicMarket v2 + +The v2 contracts add fields that the API returns only from a v2 indexer. Every one of them is +declared optional, so a response from a v1 indexer decodes unchanged and the field reads back +empty. + +| Field | Returned on | Meaning | +| --- | --- | --- | +| `current_collection_fee` | sales, auctions, buyoffers, template buyoffers | The collection's `market_fee` as of the last indexed block. The nested `collection.market_fee` is the listing-time snapshot, and the two differ while the listing is open. | +| `types` | schemas | The media type descriptors authored through `setschematyp`, exactly as stored. | +| `mutable_data`, `data` | templates | The data written by `settempldata`, and the immutable and mutable data merged. | +| `deleted_at_block`, `deleted_at_time` | templates | Set once `deltemplate` removes the template. | +| `new_author_name`, `new_author_date` | collections | The pending author succession written by `createauswap`, and the date it may be accepted. | + +A schema also reports `mediatype` and `info` inside `format[]`, but the API merges the authored +descriptors with a name and type heuristic there, so a reader cannot tell a stored value from a +derived one. `types` reports the authored ones alone. This matters to a client that calls +`setschematyp`, which replaces the whole array: resubmitting a `format[]` value writes the +heuristic's guess to chain. An empty `types` array means the schema has no descriptors, and an +absent one means the response does not report them, which is what a schema nested inside an asset +or template answers. + +### Royalties + +The AtomicMarket v2 royalty tables and the settled payout ledger are read through the v1 market +client. The example needs an indexer of an AtomicMarket v2 chain, such as the WAX testnet one +shown below: + +```ts +import {APIClient, FetchProvider} from '@wharfkit/antelope' +import {AtomicAssetsAPIClient} from '@wharfkit/atomicassets' + +const api = new AtomicAssetsAPIClient( + new APIClient({provider: new FetchProvider('https://test.wax.api.atomicassets.io')}) +) +const market = api.atomicmarket.v1 + +// The royaltyconf, royaltytemp, and royaltyattr rows of one collection. +const config = await market.get_royalty_config('mycollection') +const templateRules = await market.get_royalty_template_rules('mycollection') +const attributeRules = await market.get_royalty_attribute_rules('mycollection') + +// The payouts an account has been paid, the total number of them, and the +// per-token totals. +const payouts = await market.get_royalty_payouts({recipient: ['myaccount'], limit: 100}) +const payoutCount = await market.get_royalty_payouts_count({recipient: ['myaccount']}) +const totals = await market.get_royalty_account('myaccount') +``` + +A payout carries `amount` in the token's smallest unit, `category` naming which rule paid +(`founders`, `template`, `attribute`, or `dust`), and a nullable `listing_id`, `asset_id`, +`template_id`, and `rule_id`. `get_royalty_account` answers one row per token symbol. + +A collection with no royalty configuration answers HTTP 416, which reaches the caller as an +`APIError` with `error.response.status === 416`. On an AtomicMarket v1 chain every collection +answers that way, and the payout routes answer an empty array. An indexer without the royalty +routes answers 404 on all six methods. + ## Running Tests ``` make test ``` + +The suite never contacts the network. `@wharfkit/mock-data` replays recorded responses from +`test/data`, where each filename is a hash of the full request URL and its parameters, so +responses from two hosts never collide. To re-record one fixture, name its test: + +``` +MOCK=overwrite make test grep='get_royalty_payouts' +``` + +`MOCK=overwrite make test` without a `grep=` re-records every fixture in `test/data` against the +live hosts. The royalty fixtures come from `V2_BASE_URL` in `test/config.ts`, an AtomicMarket v2 +indexer, because the default host indexes an AtomicMarket v1 chain where those routes answer 416 +or an empty array.