From 12f6cbc0d470adb40adc3ecdd21700988619a6ba Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 17:50:28 -0400 Subject: [PATCH] feat: read the settled royalty ledger so consumers stop paging payouts by hand The Explorer client could read a collection's royalty configuration but not what the contract had actually paid, so a marketplace or a payout dashboard had to page the raw payout logs itself. The indexer already serves the settled ledger, its count, and per-account totals, one row per token symbol. This adds the three reads with typed rows, filters, and enums, and widens the three existing royalty interfaces with the row fields the indexer returns. The widening breaks only code that builds such a row by hand, which the release entry records. --- README.md | 37 ++++++++++ src/API/Explorer/Enums.ts | 30 +++++++++ src/API/Explorer/Objects.ts | 61 ++++++++++++++++- src/API/Explorer/Params.ts | 29 +++++++- src/API/Explorer/index.ts | 23 ++++++- test/royalties-api.test.ts | 91 ++++++++++++++++++++++++- test/types.test.ts | 131 +++++++++++++++++++++++++++++++++++- 7 files changed, 395 insertions(+), 7 deletions(-) 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); + }); +});