From ab146071fcdc189f4e943438a7756d0d3a430df3 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:58:01 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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. From b6be4fa86a403d02eb34a499800ec4a29512b334 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 18:36:45 -0400 Subject: [PATCH 6/6] Percent-encode caller-supplied path segments and refuse dot segments Every endpoint interpolated its NameType, UInt64Type, or string argument raw into the request path, so a value carrying a slash, a question mark, or a hash escaped its own segment and rewrote the request target, and a dot segment survived percent-encoding and was collapsed by the URL parser onto a sibling route of the same origin. One helper now encodes each segment where the path is assembled and throws on an empty, missing, or dot value, which is never an id or a name. The recorded fixtures replay unchanged, since every value they carry is already unreserved. --- src/endpoints/assets/v1.ts | 54 ++++++++++++++++++++++++-------------- src/endpoints/market/v1.ts | 48 ++++++++++++++++----------------- src/endpoints/market/v2.ts | 4 +-- src/endpoints/tools/v1.ts | 6 ++--- src/endpoints/utils.ts | 21 +++++++++++++++ test/utils.ts | 53 +++++++++++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 49 deletions(-) create mode 100644 test/utils.ts diff --git a/src/endpoints/assets/v1.ts b/src/endpoints/assets/v1.ts index c89b996..3524433 100644 --- a/src/endpoints/assets/v1.ts +++ b/src/endpoints/assets/v1.ts @@ -4,7 +4,7 @@ import type {ActionNames as ActionType} from '../../contracts/atomicassets' import {CountResponseStruct} from '../../types' import * as Assets from './types' import type {OfferState} from '../../types' -import {buildBodyParams} from '../utils' +import {buildBodyParams, pathSegment} from '../utils' export interface GetAssetsOptions { collection_name?: NameType[] @@ -233,7 +233,7 @@ export class AssetsV1APIClient { async get_asset(asset_id: UInt64Type) { return this.client.call({ - path: `/atomicassets/v1/assets/${asset_id}`, + path: `/atomicassets/v1/assets/${pathSegment(asset_id)}`, method: 'GET', responseType: Assets.GetAssetResponse, }) @@ -241,7 +241,7 @@ export class AssetsV1APIClient { async get_asset_stats(asset_id: UInt64Type) { return this.client.call({ - path: `/atomicassets/v1/assets/${asset_id}/stats`, + path: `/atomicassets/v1/assets/${pathSegment(asset_id)}/stats`, method: 'GET', responseType: Assets.GetAssetStatsResponse, }) @@ -260,7 +260,7 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/assets/${asset_id}/logs`, + path: `/atomicassets/v1/assets/${pathSegment(asset_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -294,7 +294,7 @@ export class AssetsV1APIClient { async get_collection(collection_name: NameType) { return this.client.call({ - path: `/atomicassets/v1/collections/${collection_name}`, + path: `/atomicassets/v1/collections/${pathSegment(collection_name)}`, method: 'GET', responseType: Assets.GetCollectionResponse, }) @@ -302,7 +302,7 @@ export class AssetsV1APIClient { async get_collection_stats(collection_name: NameType) { return this.client.call({ - path: `/atomicassets/v1/collections/${collection_name}/stats`, + path: `/atomicassets/v1/collections/${pathSegment(collection_name)}/stats`, method: 'GET', responseType: Assets.GetCollectionStatsResponse, }) @@ -310,7 +310,7 @@ export class AssetsV1APIClient { async get_collection_schemas(collection_name: NameType) { return this.client.call({ - path: `/atomicassets/v1/collections/${collection_name}/schemas`, + path: `/atomicassets/v1/collections/${pathSegment(collection_name)}/schemas`, method: 'GET', responseType: Assets.GetCollectionSchemasResponse, }) @@ -329,7 +329,7 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/collections/${collection_name}/logs`, + path: `/atomicassets/v1/collections/${pathSegment(collection_name)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -363,7 +363,9 @@ export class AssetsV1APIClient { async get_schema(collection_name: NameType, schema_name: NameType) { return this.client.call({ - path: `/atomicassets/v1/schemas/${collection_name}/${schema_name}`, + path: `/atomicassets/v1/schemas/${pathSegment(collection_name)}/${pathSegment( + schema_name + )}`, method: 'GET', responseType: Assets.GetSchemaResponse, }) @@ -371,7 +373,9 @@ export class AssetsV1APIClient { async get_schema_stats(collection_name: NameType, schema_name: NameType) { return this.client.call({ - path: `/atomicassets/v1/schemas/${collection_name}/${schema_name}/stats`, + path: `/atomicassets/v1/schemas/${pathSegment(collection_name)}/${pathSegment( + schema_name + )}/stats`, method: 'GET', responseType: Assets.GetSchemaStatsResponse, }) @@ -391,7 +395,9 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/schemas/${collection_name}/${schema_name}/logs`, + path: `/atomicassets/v1/schemas/${pathSegment(collection_name)}/${pathSegment( + schema_name + )}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -428,7 +434,9 @@ export class AssetsV1APIClient { async get_template(collection_name: NameType, template_id: Int32Type) { return this.client.call({ - path: `/atomicassets/v1/templates/${collection_name}/${template_id}`, + path: `/atomicassets/v1/templates/${pathSegment(collection_name)}/${pathSegment( + template_id + )}`, method: 'GET', responseType: Assets.GetTemplateResponse, }) @@ -442,8 +450,10 @@ export class AssetsV1APIClient { ) { const path = typeof template_id === 'undefined' - ? `/atomicassets/v1/templates/${collection_name_or_template_id}/stats` - : `/atomicassets/v1/templates/${collection_name_or_template_id}/${template_id}/stats` + ? `/atomicassets/v1/templates/${pathSegment(collection_name_or_template_id)}/stats` + : `/atomicassets/v1/templates/${pathSegment( + collection_name_or_template_id + )}/${pathSegment(template_id)}/stats` return this.client.call({ path, @@ -466,7 +476,9 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/templates/${collection_name}/${template_id}/logs`, + path: `/atomicassets/v1/templates/${pathSegment(collection_name)}/${pathSegment( + template_id + )}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -500,7 +512,7 @@ export class AssetsV1APIClient { async get_offer(offer_id: UInt64Type) { return this.client.call({ - path: `/atomicassets/v1/offers/${offer_id}`, + path: `/atomicassets/v1/offers/${pathSegment(offer_id)}`, method: 'GET', responseType: Assets.GetOfferResponse, }) @@ -519,7 +531,7 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/offers/${offer_id}/logs`, + path: `/atomicassets/v1/offers/${pathSegment(offer_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -586,7 +598,7 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/accounts/${account}`, + path: `/atomicassets/v1/accounts/${pathSegment(account)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -596,7 +608,9 @@ export class AssetsV1APIClient { async get_account_template_schema_count(account: NameType, collection_name: NameType) { return this.client.call({ - path: `/atomicassets/v1/accounts/${account}/${collection_name}`, + path: `/atomicassets/v1/accounts/${pathSegment(account)}/${pathSegment( + collection_name + )}`, method: 'GET', responseType: Assets.GetAccountTemplateSchemaCountResponse, }) @@ -625,7 +639,7 @@ export class AssetsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicassets/v1/burns/${account}`, + path: `/atomicassets/v1/burns/${pathSegment(account)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, diff --git a/src/endpoints/market/v1.ts b/src/endpoints/market/v1.ts index fbc7f96..740fd0c 100644 --- a/src/endpoints/market/v1.ts +++ b/src/endpoints/market/v1.ts @@ -5,7 +5,7 @@ import {CountResponseStruct} from '../../types' import * as Market from './types' import type {ActionNames as SActionType} from '../../contracts/atomicassets' import type {ActionNames as MActionType} from '../../contracts/atomicmarket' -import {buildBodyParams} from '../utils' +import {buildBodyParams, pathSegment} from '../utils' export interface GetAssetsOptions { collection_name?: NameType[] @@ -250,7 +250,7 @@ export class MarketV1APIClient { async get_asset(asset_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/assets/${asset_id}`, + path: `/atomicmarket/v1/assets/${pathSegment(asset_id)}`, method: 'GET', responseType: Market.GetAssetResponse, }) @@ -258,7 +258,7 @@ export class MarketV1APIClient { async get_asset_stats(asset_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/assets/${asset_id}/stats`, + path: `/atomicmarket/v1/assets/${pathSegment(asset_id)}/stats`, method: 'GET', responseType: Market.GetAssetStatsResponse, }) @@ -277,7 +277,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/assets/${asset_id}/logs`, + path: `/atomicmarket/v1/assets/${pathSegment(asset_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -297,7 +297,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/assets/${asset_id}/sales`, + path: `/atomicmarket/v1/assets/${pathSegment(asset_id)}/sales`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -351,7 +351,7 @@ export class MarketV1APIClient { async get_offer(offer_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/offers/${offer_id}`, + path: `/atomicmarket/v1/offers/${pathSegment(offer_id)}`, method: 'GET', responseType: Market.GetOfferResponse, }) @@ -370,7 +370,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/offers/${offer_id}/logs`, + path: `/atomicmarket/v1/offers/${pathSegment(offer_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -415,7 +415,7 @@ export class MarketV1APIClient { async get_sale(sale_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/sales/${sale_id}`, + path: `/atomicmarket/v1/sales/${pathSegment(sale_id)}`, method: 'GET', responseType: Market.GetSaleResponse, }) @@ -434,7 +434,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/sales/${sale_id}/logs`, + path: `/atomicmarket/v1/sales/${pathSegment(sale_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -510,7 +510,7 @@ export class MarketV1APIClient { async get_auction(auction_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/auctions/${auction_id}`, + path: `/atomicmarket/v1/auctions/${pathSegment(auction_id)}`, method: 'GET', responseType: Market.GetAuctionResponse, }) @@ -529,7 +529,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/auctions/${auction_id}/logs`, + path: `/atomicmarket/v1/auctions/${pathSegment(auction_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -566,7 +566,7 @@ export class MarketV1APIClient { async get_buyoffer(buyoffer_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/buyoffers/${buyoffer_id}`, + path: `/atomicmarket/v1/buyoffers/${pathSegment(buyoffer_id)}`, method: 'GET', responseType: Market.GetBuyofferResponse, }) @@ -585,7 +585,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/buyoffers/${buyoffer_id}/logs`, + path: `/atomicmarket/v1/buyoffers/${pathSegment(buyoffer_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -625,7 +625,7 @@ export class MarketV1APIClient { async get_template_buyoffer(buyoffer_id: UInt64Type) { return this.client.call({ - path: `/atomicmarket/v1/template_buyoffers/${buyoffer_id}`, + path: `/atomicmarket/v1/template_buyoffers/${pathSegment(buyoffer_id)}`, method: 'GET', responseType: Market.GetTemplateBuyofferResponse, }) @@ -644,7 +644,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/template_buyoffers/${buyoffer_id}/logs`, + path: `/atomicmarket/v1/template_buyoffers/${pathSegment(buyoffer_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -662,7 +662,7 @@ export class MarketV1APIClient { async get_marketplace(marketplace_name: string) { return this.client.call({ - path: `/atomicmarket/v1/marketplaces/${marketplace_name}`, + path: `/atomicmarket/v1/marketplaces/${pathSegment(marketplace_name)}`, method: 'GET', responseType: Market.GetMarketplaceResponse, }) @@ -789,7 +789,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/prices/inventory/${account}`, + path: `/atomicmarket/v1/prices/inventory/${pathSegment(account)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -833,7 +833,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/stats/collections/${collection_name}`, + path: `/atomicmarket/v1/stats/collections/${pathSegment(collection_name)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -875,7 +875,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/stats/accounts/${account}`, + path: `/atomicmarket/v1/stats/accounts/${pathSegment(account)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -898,7 +898,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/stats/schemas/${collection_name}`, + path: `/atomicmarket/v1/stats/schemas/${pathSegment(collection_name)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -1009,7 +1009,7 @@ export class MarketV1APIClient { */ async get_royalty_config(collection_name: NameType) { return this.client.call({ - path: `/atomicmarket/v1/royalties/${collection_name}`, + path: `/atomicmarket/v1/royalties/${pathSegment(collection_name)}`, method: 'GET', responseType: Market.GetRoyaltyConfigResponse, }) @@ -1026,7 +1026,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/royalties/${collection_name}/templates`, + path: `/atomicmarket/v1/royalties/${pathSegment(collection_name)}/templates`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -1046,7 +1046,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/royalties/${collection_name}/attributes`, + path: `/atomicmarket/v1/royalties/${pathSegment(collection_name)}/attributes`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, @@ -1098,7 +1098,7 @@ export class MarketV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v1/royalties/accounts/${account}`, + path: `/atomicmarket/v1/royalties/accounts/${pathSegment(account)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, diff --git a/src/endpoints/market/v2.ts b/src/endpoints/market/v2.ts index eabadef..49c3df2 100644 --- a/src/endpoints/market/v2.ts +++ b/src/endpoints/market/v2.ts @@ -3,7 +3,7 @@ import type {Float64Type, Int32Type, NameType, UInt64Type} from '@wharfkit/antel import type {SaleState} from '../../types' import {CountResponseStruct} from '../../types' import * as Market from './types' -import {buildBodyParams} from '../utils' +import {buildBodyParams, pathSegment} from '../utils' export interface GetSalesOptions { state?: SaleState[] @@ -96,7 +96,7 @@ export class MarketV2APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomicmarket/v2/stats/schemas/${collection_name}`, + path: `/atomicmarket/v2/stats/schemas/${pathSegment(collection_name)}`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, diff --git a/src/endpoints/tools/v1.ts b/src/endpoints/tools/v1.ts index a36c1f2..412d81f 100644 --- a/src/endpoints/tools/v1.ts +++ b/src/endpoints/tools/v1.ts @@ -4,7 +4,7 @@ import type {LinkState} from '../../types' import {CountResponseStruct} from '../../types' import * as Tools from './types' import type {ActionNames as ActionType} from '../../contracts/atomictoolsx' -import {buildBodyParams} from '../utils' +import {buildBodyParams, pathSegment} from '../utils' export interface GetLinksOptions { creator?: NameType[] @@ -54,7 +54,7 @@ export class ToolsV1APIClient { async get_link(link_id: UInt64Type) { return this.client.call({ - path: `/atomictools/v1/links/${link_id}`, + path: `/atomictools/v1/links/${pathSegment(link_id)}`, method: 'GET', responseType: Tools.GetLinkResponse, }) @@ -73,7 +73,7 @@ export class ToolsV1APIClient { const bodyParams = buildBodyParams(options) return this.client.call({ - path: `/atomictools/v1/links/${link_id}/logs`, + path: `/atomictools/v1/links/${pathSegment(link_id)}/logs`, method: 'POST', params: bodyParams, headers: {'Content-Type': 'application/json'}, diff --git a/src/endpoints/utils.ts b/src/endpoints/utils.ts index 48cd95c..d92111d 100644 --- a/src/endpoints/utils.ts +++ b/src/endpoints/utils.ts @@ -41,3 +41,24 @@ export function serializeQueryParams(params?: {[key: string]: any}): {[key: stri return result } + +/** + * Encode a value for use as one path segment. + * + * An empty value or a dot segment would rewrite the request path, so both are rejected, as is a + * missing value, which would otherwise travel as the literal segment 'undefined'. A value that + * equals a sibling route literal such as '_count' is a valid segment and is not rejected here. + */ +export function pathSegment(value: unknown): string { + if (value === null || value === undefined) { + throw new Error('Invalid path segment: a value is required') + } + const segment = String(value) + if (segment === '' || segment === '.' || segment === '..') { + throw new Error( + `Invalid path segment '${segment}': an empty or dot segment rewrites the request path` + ) + } + + return encodeURIComponent(segment) +} diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..e7e4c58 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,53 @@ +import {assert} from 'chai' +import {APIClient, FetchProvider, Name, UInt64} from '@wharfkit/antelope' +import {mockFetch} from '@wharfkit/mock-data' +import {BASE_URL, TIMEOUT, SLOW_THRESHOLD} from './config' + +import {AtomicAssetsAPIClient} from '$lib' +import {pathSegment} from '../src/endpoints/utils' + +// Setup the API +const atomicassets = new AtomicAssetsAPIClient( + new APIClient({ + provider: new FetchProvider(BASE_URL, {fetch: mockFetch}), + }) +) + +suite('utils', function () { + this.slow(SLOW_THRESHOLD) + this.timeout(TIMEOUT) + + test('pathSegment encodes characters that rewrite the path', function () { + assert.equal(pathSegment('alien/worlds'), 'alien%2Fworlds') + assert.equal(pathSegment('alien?worlds'), 'alien%3Fworlds') + assert.equal(pathSegment('alien#worlds'), 'alien%23worlds') + assert.equal(pathSegment('alien worlds'), 'alien%20worlds') + assert.equal(pathSegment('alien&worlds'), 'alien%26worlds') + }) + + test('pathSegment returns the text of a Name and a UInt64', function () { + assert.equal(pathSegment(Name.from('alice')), 'alice') + assert.equal(pathSegment(UInt64.from(5)), '5') + }) + + test('pathSegment rejects an empty value and the dot segments', function () { + assert.throws(() => pathSegment(''), /Invalid path segment/) + assert.throws(() => pathSegment('.'), /Invalid path segment/) + assert.throws(() => pathSegment('..'), /Invalid path segment/) + assert.throws(() => pathSegment(null), /a value is required/) + assert.throws(() => pathSegment(undefined), /a value is required/) + }) + + test('get_asset rejects a dot segment before it requests anything', async function () { + let error: unknown + + try { + await atomicassets.atomicassets.v1.get_asset('..') + } catch (caught) { + error = caught + } + + assert.instanceOf(error, Error) + assert.match((error as Error).message, /^Invalid path segment '\.\.'/) + }) +})