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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions src/API/Explorer/Enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
61 changes: 60 additions & 1 deletion src/API/Explorer/Objects.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
29 changes: 28 additions & 1 deletion src/API/Explorer/Params.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<OfferApiParams, 'state'> & { 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;
}
23 changes: 21 additions & 2 deletions src/API/Explorer/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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<IRoyaltyPayout[]> {
return await this.fetchEndpoint('/v1/royalties/payouts', {page, limit, ...options});
}

async countRoyaltyPayouts(options: RoyaltyPayoutApiParams = {}): Promise<number> {
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<IRoyaltyAccountTotal[]> {
return await this.fetchEndpoint('/v1/royalties/accounts/' + encodeURIComponent(account), options);
}

/* PRICE API */
async getPriceHistory(
options: BaseAssetFilterParams & {symbol?: string} = {}
Expand Down
91 changes: 89 additions & 2 deletions test/royalties-api.test.ts
Original file line number Diff line number Diff line change
@@ -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 };

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
Loading
Loading