diff --git a/README.md b/README.md index 4ddbc92..9cb7f0e 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,29 @@ const templateRules = await api.getRoyaltyTemplateRules('mycollection'); const attributeRules = await api.getRoyaltyAttributeRules('mycollection'); ``` +What the collection has actually paid is a separate read. The indexer keeps one ledger row for every royalty the contract settled, and aggregates one account's rows per token: + +```ts +const payouts = await api.getRoyaltyPayouts({ + collection_name: 'mycollection', + recipient: 'founderacct1', + category: 'template' +}, 1, 100); + +const settled = await api.countRoyaltyPayouts({collection_name: 'mycollection'}); + +// one row per token symbol the account has been paid in +const earned = await api.getRoyaltyAccount('founderacct1'); +``` + +`amount` is in raw token units, so read it against the `token_precision` of the same row: `5000000` at precision 8 is `0.05000000 WAX`. The same holds for the `amount` on a `getRoyaltyAccount` row, which sums the payouts the filters admit, and its `payout_count` is a decimal string rather than a number. + +`category` names the rule that paid, one of `founders`, `template`, `attribute`, or `dust`, and it tells you which linkage the row carries: a template payout sets `template_id`, an attribute payout sets `rule_id`, and a founders or dust payout sets neither. A dust row is the rounding remainder plus the author fallback, paid to the collection author, and it names no asset either. `listing_id` is null when `listing_type` is `unresolved`, which is the row the indexer keeps when it cannot trace a settlement back to the listing that triggered it. A row whose stored value falls outside the vocabulary this SDK serves reads null for both `listing_type` and `category`. + +The ledger pages like the listing routes. It sorts newest first by default, takes `sort` of `created` or `amount`, `order` of `asc` or `desc`, a `limit` up to 100, and `lower_bound`, `upper_bound`, or `ids` over `log_global_sequence`. `RoyaltyListingType`, `RoyaltyPayoutCategory`, and `RoyaltyPayoutSort` are exported for the filter values. + +On a chain still running AtomicMarket v1 the contract logs no payouts and configures no royalties, so the ledger reads empty, the count is zero, and every collection answers `getRoyaltyConfig` with null. An indexer built before the royalty routes existed is a different case: it answers 404, which arrives as an `ApiError` with `status` 404 from every one of these methods. Only the HTTP 416 that `getRoyaltyConfig` receives for a collection with no royalty config becomes null; the ledger and count routes return empty results instead, never null. + ## Writing royalty configuration Reading needs no signing. To change a collection's royalty split, this SDK builds the action objects and hands them to whatever signing library you already use. It does not sign or broadcast anything itself. @@ -404,6 +427,20 @@ Two of them do foreclose a purchase the chain would have taken, deliberately. Re Nothing here reads chain state. Whether a symbol is supported, and whether a pairing of two is registered, is chain state, which is why `announceSaleActions` checks nothing at all and why the settlement amount an oracle-settled sale deposits goes unchecked here, the helper never being handed the pair it derives from. Bound anything else you read from a response before you trust it. +## What's new in 2.4.0 + +Reads the settled royalty ledger, so a consumer no longer has to page the payout logs itself. + +### Breaking changes + +- `IRoyaltyConfig`, `IRoyaltyTemplateRule`, and `IRoyaltyAttributeRule` gain required `market_contract`, `collection_name`, and timestamp fields, and the attribute rule gains `lookup_hash`. Reading a response is unaffected. Code that builds one of these rows by hand, such as a test mock, must supply the added fields. (#23) + +### Features + +- `getRoyaltyPayouts`, `countRoyaltyPayouts`, and `getRoyaltyAccount` cover the AtomicMarket v2 payout ledger: every settled royalty, the count behind it, and one account's totals per token symbol. Payout filters travel as `RoyaltyPayoutApiParams`, whose primary boundary ranges over `log_global_sequence`. The account totals take the date window alone, because that route groups the boundary column away. (#23) +- `IRoyaltyPayout` and `IRoyaltyAccountTotal` type the two new row shapes, and `RoyaltyListingType`, `RoyaltyPayoutCategory`, and `RoyaltyPayoutSort` pin the strings the indexer serves and filters on. (#23) +- `IRoyaltyConfig`, `IRoyaltyTemplateRule`, and `IRoyaltyAttributeRule` carry the `market_contract`, `collection_name`, and four timestamps of their rows, and the attribute rule also carries its `lookup_hash`. (#23) + ## What's new in 2.3.0 Adds the auction, buy-offer and template-buy-offer builders and aligns the purchase path with the v2 contract. diff --git a/src/API/Explorer/Enums.ts b/src/API/Explorer/Enums.ts index 80202e8..0ef49c3 100644 --- a/src/API/Explorer/Enums.ts +++ b/src/API/Explorer/Enums.ts @@ -89,3 +89,33 @@ export enum TransferSort { export enum OfferSort { Created = 'created' } + +// Royalty payout ledger vocabulary (/v1/royalties/payouts). The indexer stores +// each of these as a small integer and serves the name, and it filters on the +// name too, so these are the values a query carries. + +// Which listing settled the payout. Unresolved is a real stored value, not a +// missing one: the filler keeps a payout whose settlement action it could not +// trace back to a listing, and such a row carries a null listing_id. +export enum RoyaltyListingType { + Unresolved = 'unresolved', + Sale = 'sale', + Auction = 'auction', + Buyoffer = 'buyoffer', + TemplateBuyoffer = 'template_buyoffer' +} + +// Which royalty rule paid. Dust is the settlement remainder plus the author +// fallback, so a dust row pays the collection author and names no asset, +// template, or rule. +export enum RoyaltyPayoutCategory { + Founders = 'founders', + Template = 'template', + Attribute = 'attribute', + Dust = 'dust' +} + +export enum RoyaltyPayoutSort { + Created = 'created', + Amount = 'amount' +} diff --git a/src/API/Explorer/Objects.ts b/src/API/Explorer/Objects.ts index ffe8d1b..aa774f1 100644 --- a/src/API/Explorer/Objects.ts +++ b/src/API/Explorer/Objects.ts @@ -1,6 +1,6 @@ import { IAsset, ILightCollection, IOffer, ITransfer } from '@atomichub/atomicassets'; -import { AuctionState, BuyofferState, SaleState } from './Enums'; +import { AuctionState, BuyofferState, RoyaltyListingType, RoyaltyPayoutCategory, SaleState } from './Enums'; export interface IMarketPair { listing_symbol: string; @@ -175,19 +175,33 @@ export interface IRoyaltyRecipient { } export interface IRoyaltyConfig { + market_contract: string; + collection_name: string; founders: IRoyaltyRecipient[]; attribute_mode: number; split_founders: string; split_templates: string; split_attributes: string; + updated_at_block: string; + updated_at_time: string; + created_at_block: string; + created_at_time: string; } export interface IRoyaltyTemplateRule { + market_contract: string; + collection_name: string; template_id: string; recipients: IRoyaltyRecipient[]; + updated_at_block: string; + updated_at_time: string; + created_at_block: string; + created_at_time: string; } export interface IRoyaltyAttributeRule { + market_contract: string; + collection_name: string; rule_id: string; source: number; field: string; @@ -196,4 +210,49 @@ export interface IRoyaltyAttributeRule { value: [string, unknown]; weight: string; recipients: IRoyaltyRecipient[]; + // Hex-encoded sha256 of the attribute the rule matches, the same digest the + // contract looks the rule up by. + lookup_hash: string; + updated_at_block: string; + updated_at_time: string; + created_at_block: string; + created_at_time: string; +} + +// One settled payout, keyed by the settlement log's global sequence and by the +// entry's position in that log's payout vector. +export interface IRoyaltyPayout extends IMarketToken { + market_contract: string; + log_global_sequence: string; + payout_index: number; + // Null when the stored value falls outside the vocabulary this SDK + // serves. + listing_type: RoyaltyListingType | null; + // Null when the listing type is unresolved, which is the row the filler + // keeps when it cannot trace the settlement back to a listing. + listing_id: string | null; + // Null when the stored value falls outside the vocabulary this SDK + // serves. + category: RoyaltyPayoutCategory | null; + collection_name: string; + asset_id: string | null; + // The category picks which of the two is set: a template payout carries + // template_id, an attribute payout carries rule_id, and a founders or dust + // payout carries neither. + template_id: string | null; + rule_id: string | null; + recipient: string; + // Raw token units, read with the token_precision of this same row. + amount: string; + txid: string; + created_at_block: string; + created_at_time: string; +} + +// One row per token symbol the account has been paid in, summed over the +// payouts the filters admit. Both totals are decimal strings, payout_count +// included, because the API serves a SQL count as a string. +export interface IRoyaltyAccountTotal extends IMarketToken { + amount: string; + payout_count: string; } diff --git a/src/API/Explorer/Params.ts b/src/API/Explorer/Params.ts index 963a116..0f990dc 100644 --- a/src/API/Explorer/Params.ts +++ b/src/API/Explorer/Params.ts @@ -1,6 +1,6 @@ import { AssetFilterParams, DateBoundaryParams, OfferApiParams, OfferState, OrderParam, PrimaryBoundaryParams } from '@atomichub/atomicassets'; -import { AuctionSort, AuctionState, BuyofferSort, BuyofferState, SaleSort, SaleState } from './Enums'; +import { AuctionSort, AuctionState, BuyofferSort, BuyofferState, RoyaltyListingType, RoyaltyPayoutCategory, RoyaltyPayoutSort, SaleSort, SaleState } from './Enums'; export interface ListingFilterParams { max_assets?: number; @@ -57,3 +57,30 @@ export interface BuyofferApiParams extends ListingFilterParams, AssetFilterParam // carries the same `[key: string]: any` index signature as those three, so // getOffers and countOffers share one filter surface. export type MarketOfferApiParams = Omit & { state?: OfferState | string, [key: string]: any }; + +// Filters for the settled royalty payout ledger. recipient, collection_name, +// asset_id, symbol, and category each take one value or several joined with +// commas, as the sibling list filters do. The primary boundary (ids, +// lower_bound, upper_bound) ranges over log_global_sequence, the payout +// ledger's primary column. +export interface RoyaltyPayoutApiParams extends PrimaryBoundaryParams, DateBoundaryParams { + recipient?: string; + collection_name?: string; + asset_id?: string; + symbol?: string; + listing_type?: RoyaltyListingType | string; + listing_id?: string; + category?: RoyaltyPayoutCategory | string; + sort?: RoyaltyPayoutSort | string; + order?: OrderParam; + [key: string]: any; +} + +// Filters for the per-account totals. The route groups the payouts by token +// symbol, so it has no primary column left to bound and takes the date window +// alone. +export interface RoyaltyAccountApiParams extends DateBoundaryParams { + collection_name?: string; + symbol?: string; + [key: string]: any; +} diff --git a/src/API/Explorer/index.ts b/src/API/Explorer/index.ts index f72ee5e..81e6575 100644 --- a/src/API/Explorer/index.ts +++ b/src/API/Explorer/index.ts @@ -1,8 +1,8 @@ import { AssetsApiParams, ILog, TransferApiParams } from '@atomichub/atomicassets'; import ApiError from '../../Errors/ApiError'; -import { AuctionApiParams, BaseAssetFilterParams, BuyofferApiParams, MarketOfferApiParams, SaleApiParams } from './Params'; -import { IAuction, IBuyoffer, IMarketAsset, IMarketConfig, IMarketOffer, IMarketplace, IMarketToken, IMarketTransfer, IPriceStats, IRoyaltyAttributeRule, IRoyaltyConfig, IRoyaltyTemplateRule, ISale } from './Objects'; +import { AuctionApiParams, BaseAssetFilterParams, BuyofferApiParams, MarketOfferApiParams, RoyaltyAccountApiParams, RoyaltyPayoutApiParams, SaleApiParams } from './Params'; +import { IAuction, IBuyoffer, IMarketAsset, IMarketConfig, IMarketOffer, IMarketplace, IMarketToken, IMarketTransfer, IPriceStats, IRoyaltyAccountTotal, IRoyaltyAttributeRule, IRoyaltyConfig, IRoyaltyPayout, IRoyaltyTemplateRule, ISale } from './Objects'; type Fetch = typeof fetch; type ApiArgs = { fetch?: Fetch }; @@ -134,6 +134,25 @@ export default class AtomicMarketApi { return await this.fetchEndpoint('/v1/royalties/' + encodeURIComponent(collection) + '/attributes', {page, limit}); } + // The settled payout ledger, newest first, one row for each entry in a + // settlement log's payout vector. A chain still running AtomicMarket v1 logs no payouts, so + // there the route answers an empty array rather than an error. An indexer + // built before the royalty routes answers 404, which arrives as an + // ApiError and is a different case from the 416 above. + async getRoyaltyPayouts(options: RoyaltyPayoutApiParams = {}, page: number = 1, limit: number = 100): Promise { + return await this.fetchEndpoint('/v1/royalties/payouts', {page, limit, ...options}); + } + + async countRoyaltyPayouts(options: RoyaltyPayoutApiParams = {}): Promise { + return await this.countEndpoint('/v1/royalties/payouts', options); + } + + // What one account has been paid, one row per token symbol. An account + // paid in two tokens returns two rows, and one never paid returns none. + async getRoyaltyAccount(account: string, options: RoyaltyAccountApiParams = {}): Promise { + return await this.fetchEndpoint('/v1/royalties/accounts/' + encodeURIComponent(account), options); + } + /* PRICE API */ async getPriceHistory( options: BaseAssetFilterParams & {symbol?: string} = {} diff --git a/test/royalties-api.test.ts b/test/royalties-api.test.ts index ec6da91..e5f325b 100644 --- a/test/royalties-api.test.ts +++ b/test/royalties-api.test.ts @@ -1,6 +1,8 @@ import { expect } from 'chai'; -import { ApiError, AtomicMarketApi, IRoyaltyConfig } from '../src'; +import { OrderParam } from '@atomichub/atomicassets'; + +import { ApiError, AtomicMarketApi, IRoyaltyAccountTotal, IRoyaltyConfig, IRoyaltyPayout, RoyaltyListingType, RoyaltyPayoutCategory, RoyaltyPayoutSort } from '../src'; type FetchCall = { url: string }; @@ -20,11 +22,17 @@ function mockApi(handler: (url: string) => {status: number, body: any}, calls: F describe('AtomicMarketApi royalty read endpoints', () => { const config: IRoyaltyConfig = { + market_contract: 'atomicmarket', + collection_name: 'mycollection', founders: [{recipient: 'alice', weight: 5000}], attribute_mode: 1, split_founders: '5000', split_templates: '3000', - split_attributes: '2000' + split_attributes: '2000', + updated_at_block: '221419712', + updated_at_time: '1750000000000', + created_at_block: '221419700', + created_at_time: '1749999000000' }; it('getRoyaltyConfig fetches /v1/royalties/{collection} and returns the config', async () => { @@ -74,4 +82,83 @@ describe('AtomicMarketApi royalty read endpoints', () => { expect(calls[0].url).to.equal('https://test.api/atomicmarket/v1/royalties/1%2F2%20%3F%26%23x/templates?page=2&limit=50'); }); + + // A payout row as the WAX testnet indexer serves it: hex txid, decimal + // string amount, and the template linkage set while rule_id stays null. + const payout: IRoyaltyPayout = { + market_contract: 'atomicmarket', + log_global_sequence: '4126381854', + payout_index: 1, + listing_type: RoyaltyListingType.Sale, + listing_id: '2199023255614', + category: RoyaltyPayoutCategory.Template, + collection_name: 'royaltycol11', + asset_id: '1099512960221', + template_id: '703531', + rule_id: null, + recipient: 'jacktestr125', + amount: '5000000', + token_symbol: 'WAX', + token_precision: 8, + token_contract: 'eosio.token', + txid: 'a5f2ab8f2a0f6d3e4f1c8b7d9e0a1b2c3d4e5f60718293a4b5c6d7e8f9012345', + created_at_block: '221419712', + created_at_time: '1750000000000' + }; + + const accountTotal: IRoyaltyAccountTotal = { + token_symbol: 'WAX', + token_precision: 8, + token_contract: 'eosio.token', + amount: '150000000', + payout_count: '3' + }; + + it('getRoyaltyPayouts pages the ledger and carries the filters into the query', async () => { + const calls: FetchCall[] = []; + const api = mockApi(() => ({status: 200, body: {success: true, data: [payout]}}), calls); + + const rows = await api.getRoyaltyPayouts({ + recipient: 'jacktestr125', + collection_name: 'royaltycol11', + category: RoyaltyPayoutCategory.Template, + listing_type: RoyaltyListingType.Sale, + sort: RoyaltyPayoutSort.Amount, + order: OrderParam.Asc + }, 2, 50); + + expect(rows).to.deep.equal([payout]); + expect(calls[0].url).to.equal( + 'https://test.api/atomicmarket/v1/royalties/payouts' + + '?page=2&limit=50&recipient=jacktestr125&collection_name=royaltycol11' + + '&category=template&listing_type=sale&sort=amount&order=asc' + ); + }); + + it('countRoyaltyPayouts reads the count route and parses the decimal string', async () => { + const calls: FetchCall[] = []; + const api = mockApi(() => ({status: 200, body: {success: true, data: '20'}}), calls); + + expect(await api.countRoyaltyPayouts({collection_name: 'royaltycol11'})).to.equal(20); + + expect(calls[0].url).to.equal('https://test.api/atomicmarket/v1/royalties/payouts/_count?collection_name=royaltycol11'); + }); + + it('getRoyaltyAccount returns the per-token totals for one recipient', async () => { + const calls: FetchCall[] = []; + const api = mockApi(() => ({status: 200, body: {success: true, data: [accountTotal]}}), calls); + + expect(await api.getRoyaltyAccount('jacktestr125', {collection_name: 'royaltycol11'})).to.deep.equal([accountTotal]); + + expect(calls[0].url).to.equal('https://test.api/atomicmarket/v1/royalties/accounts/jacktestr125?collection_name=royaltycol11'); + }); + + it('percent-encodes a hostile account name in the path', async () => { + const calls: FetchCall[] = []; + const api = mockApi(() => ({status: 200, body: {success: true, data: []}}), calls); + + await api.getRoyaltyAccount('1/2 ?&#x'); + + expect(calls[0].url).to.equal('https://test.api/atomicmarket/v1/royalties/accounts/1%2F2%20%3F%26%23x'); + }); }); diff --git a/test/types.test.ts b/test/types.test.ts index 80a487d..5dc17ba 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { AuctionState, BuyofferState, IAuction, IBuyoffer, ISale, SaleState, TemplateBuyofferState } from '../src'; +import { AuctionState, BuyofferState, IAuction, IBuyoffer, IRoyaltyAccountTotal, IRoyaltyAttributeRule, IRoyaltyConfig, IRoyaltyPayout, IRoyaltyTemplateRule, ISale, RoyaltyListingType, RoyaltyPayoutCategory, RoyaltyPayoutSort, SaleState, TemplateBuyofferState } from '../src'; describe('v2 current_collection_fee type field', () => { it('ISale/IAuction/IBuyoffer type-check without current_collection_fee and read undefined', () => { @@ -71,3 +71,132 @@ describe('listing state enums', () => { expect(TemplateBuyofferState.Sold).to.equal(2); }); }); + +describe('royalty read-layer types', () => { + // The indexer stores each of these as a number and translates on the way + // out, so the names below are the whole vocabulary a filter may use. They + // pin the LISTING_TYPE_BY_NAME and PAYOUT_CATEGORY_BY_NAME maps and the + // sort allowedValues in atomicassets-api + // src/api/namespaces/atomicmarket/handlers/royalties.ts. + it('pins the payout enums to the strings the indexer serves and filters on', () => { + expect(namedMembers(RoyaltyListingType)).to.deep.equal({ + Unresolved: 'unresolved', + Sale: 'sale', + Auction: 'auction', + Buyoffer: 'buyoffer', + TemplateBuyoffer: 'template_buyoffer' + }); + + expect(namedMembers(RoyaltyPayoutCategory)).to.deep.equal({ + Founders: 'founders', Template: 'template', Attribute: 'attribute', Dust: 'dust' + }); + + expect(namedMembers(RoyaltyPayoutSort)).to.deep.equal({Created: 'created', Amount: 'amount'}); + }); + + it('type-checks the payout, account total, config and rule rows against indexer shapes', () => { + const attributePayout: IRoyaltyPayout = { + market_contract: 'atomicmarket', + log_global_sequence: '4126381854', + payout_index: 2, + listing_type: RoyaltyListingType.Auction, + listing_id: '2199023255700', + category: RoyaltyPayoutCategory.Attribute, + collection_name: 'royaltycol11', + asset_id: '1099512960221', + template_id: null, + rule_id: '3', + recipient: 'jacktestr125', + amount: '2500000', + token_symbol: 'WAX', + token_precision: 8, + token_contract: 'eosio.token', + txid: 'b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899aabbccddeeff01', + created_at_block: '221419712', + created_at_time: '1750000000000' + }; + + // A dust payout of an unresolved settlement: no listing, no asset, no + // template, no rule, and the collection author as the recipient. + const dustPayout: IRoyaltyPayout = { + ...attributePayout, + payout_index: 0, + listing_type: RoyaltyListingType.Unresolved, + listing_id: null, + category: RoyaltyPayoutCategory.Dust, + asset_id: null, + rule_id: null, + recipient: 'royaltyauth1', + amount: '3' + }; + + // A row whose stored value falls outside the vocabulary this SDK + // serves: the indexer keeps it rather than drop it. + const unmappedPayout: IRoyaltyPayout = { + ...attributePayout, + payout_index: 1, + listing_type: null, + category: null + }; + + const accountTotal: IRoyaltyAccountTotal = { + token_symbol: 'WAX', + token_precision: 8, + token_contract: 'eosio.token', + amount: '150000000', + payout_count: '3' + }; + + const config: IRoyaltyConfig = { + market_contract: 'atomicmarket', + collection_name: 'royaltycol11', + founders: [{recipient: 'jacktestr125', weight: 1}], + attribute_mode: 1, + split_founders: '5000', + split_templates: '2500', + split_attributes: '2500', + updated_at_block: '221419712', + updated_at_time: '1750000000000', + created_at_block: '221419700', + created_at_time: '1749999000000' + }; + + const templateRule: IRoyaltyTemplateRule = { + market_contract: 'atomicmarket', + collection_name: 'royaltycol11', + template_id: '703531', + recipients: [{recipient: 'jacktestr125', weight: 1}], + updated_at_block: '221419712', + updated_at_time: '1750000000000', + created_at_block: '221419700', + created_at_time: '1749999000000' + }; + + const attributeRule: IRoyaltyAttributeRule = { + market_contract: 'atomicmarket', + collection_name: 'royaltycol11', + rule_id: '3', + source: 0, + field: 'rarity', + value: ['string', 'legendary'], + weight: '1', + recipients: [{recipient: 'jacktestr125', weight: 1}], + lookup_hash: '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b', + updated_at_block: '221419712', + updated_at_time: '1750000000000', + created_at_block: '221419700', + created_at_time: '1749999000000' + }; + + expect(attributePayout.template_id).to.equal(null); + expect(attributePayout.rule_id).to.equal('3'); + expect(dustPayout.listing_id).to.equal(null); + expect(dustPayout.asset_id).to.equal(null); + expect(unmappedPayout.listing_type).to.equal(null); + expect(unmappedPayout.category).to.equal(null); + expect(accountTotal.payout_count).to.equal('3'); + expect(config.market_contract).to.equal('atomicmarket'); + expect(templateRule.created_at_time).to.equal('1749999000000'); + expect(attributeRule.lookup_hash).to.have.lengthOf(64); + }); +});