diff --git a/src/model/asset_id.ts b/src/model/asset_id.ts new file mode 100644 index 00000000..5c7860ce --- /dev/null +++ b/src/model/asset_id.ts @@ -0,0 +1,2 @@ +// String like USD-GBSTRUSD7IRX73RQZBL3RQUH6KS3O4NYFY3QCALDLZD77XMZOPWAVTUK +export type AssetID = string; diff --git a/src/model/balance.ts b/src/model/balance.ts index 138d3fbd..101d3e21 100644 --- a/src/model/balance.ts +++ b/src/model/balance.ts @@ -1,6 +1,5 @@ import { Asset } from "stellar-sdk"; -import { toFloatAmountString } from "../util/stellar"; -import { AccountID } from "./account_id"; +import { AccountID, AssetID } from "./"; export interface IBalanceBase { account: AccountID; @@ -15,6 +14,12 @@ export interface IBalance extends IBalanceBase { } export class Balance implements IBalance { + public static parsePagingToken(token: string): [AccountID, AssetID, string] { + return Buffer.from(token, "base64") + .toString() + .split("_") as [AccountID, AssetID, string]; + } + public account: AccountID; public asset: Asset; public limit: string; @@ -24,10 +29,14 @@ export class Balance implements IBalance { constructor(data: IBalance) { this.account = data.account; - this.limit = toFloatAmountString(data.limit); - this.balance = toFloatAmountString(data.balance); + this.limit = data.limit; + this.balance = data.balance; this.lastModified = data.lastModified; this.authorized = data.authorized; this.asset = data.asset; } + + public get paging_token() { + return Buffer.from(`${this.account}_${this.asset.toString()}_${this.balance}`).toString("base64"); + } } diff --git a/src/model/balance_values.ts b/src/model/balance_values.ts index 0f275dbe..bd8e681d 100644 --- a/src/model/balance_values.ts +++ b/src/model/balance_values.ts @@ -1,5 +1,4 @@ import { Asset } from "stellar-sdk"; -import { toFloatAmountString } from "../util/stellar"; import { IBalanceBase } from "./balance"; export class BalanceValues implements IBalanceBase { @@ -11,8 +10,8 @@ export class BalanceValues implements IBalanceBase { constructor(data: IBalanceBase) { this.account = data.account; - this.limit = toFloatAmountString(data.limit); - this.balance = toFloatAmountString(data.balance); + this.limit = data.limit; + this.balance = data.balance; this.authorized = data.authorized; this.asset = data.asset; } diff --git a/src/model/index.ts b/src/model/index.ts index bd3897be..fbbb533a 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -4,6 +4,7 @@ export * from "./account_thresholds"; export * from "./account_flags"; export * from "./account_values"; export * from "./account_subscription_payload"; +export * from "./asset_id"; export * from "./asset_input"; export * from "./data_entry"; export * from "./data_entry_subscription_payload"; diff --git a/src/repo/assets.ts b/src/repo/assets.ts index 68d1e597..c0c51d52 100644 --- a/src/repo/assets.ts +++ b/src/repo/assets.ts @@ -1,6 +1,10 @@ import { IDatabase } from "pg-promise"; import squel from "squel"; -import { Asset } from "stellar-base"; +import { Asset } from "stellar-sdk"; +import { PagingParams } from "../datasource/horizon/base"; +import { Balance } from "../model"; +import { BalanceFactory } from "../model/factories"; +import { parseCursorPagination, properlyOrdered, SortOrder } from "../util/paging"; export default class AssetsRepo { private db: IDatabase; @@ -39,4 +43,31 @@ export default class AssetsRepo { return res.map(a => new Asset(a.assetcode, a.issuer)); } + + public async findHolders(asset: Asset, paging: PagingParams) { + const { limit, cursor, order } = parseCursorPagination(paging); + const queryBuilder = squel + .select() + .from("trustlines") + .where("assetcode = ?", asset.getCode()) + .where("issuer = ?", asset.getIssuer()) + .order("balance", order === SortOrder.ASC) // we must order by balance first to support cursor pagination + .order("accountid", order !== SortOrder.ASC) // inversion of above + .limit(limit); + + if (cursor) { + const [accountId, , balance] = Balance.parsePagingToken(cursor); + + if (paging.after) { + queryBuilder.where("(balance = ? AND accountid > ?) OR balance < ?", balance, accountId, balance); + } else if (paging.before) { + queryBuilder.where("(balance = ? AND accountid < ?) OR balance > ?", balance, accountId, balance); + } + } + + const res = await this.db.manyOrNone(queryBuilder.toString()); + const balances = res.map(r => BalanceFactory.fromDb(r)); + + return properlyOrdered(balances, paging); + } } diff --git a/src/schema/assets.ts b/src/schema/assets.ts index 39042f15..4716ad24 100644 --- a/src/schema/assets.ts +++ b/src/schema/assets.ts @@ -14,6 +14,8 @@ export const typeDefs = gql` issuer: Account "Asset's code" code: AssetCode! + "All accounts that trust this asset, ordered by balance" + balances(first: Int, last: Int, after: String, before: String): BalanceConnection } "Represents single [asset](https://www.stellar.org/developers/guides/concepts/assets.html) on Stellar network with additional statistics, provided by Horizon" @@ -30,6 +32,8 @@ export const typeDefs = gql` numAccounts: Int "Asset's issuer account flags" flags: AccountFlags + "All accounts that trust this asset, ordered by balance" + balances(first: Int, last: Int, after: String, before: String): BalanceConnection } "A list of assets" @@ -50,6 +54,8 @@ export const typeDefs = gql` } type Query { + "Get single asset" + asset(id: AssetID): Asset "Get list of assets" assets( code: AssetCode diff --git a/src/schema/resolvers/asset.ts b/src/schema/resolvers/asset.ts index 7b24a89f..b742844f 100644 --- a/src/schema/resolvers/asset.ts +++ b/src/schema/resolvers/asset.ts @@ -1,12 +1,25 @@ +import { Asset } from "stellar-sdk"; +import { db } from "../../database"; import { IHorizonAssetData } from "../../datasource/types"; import { IApolloContext } from "../../graphql_server"; +import { AssetID } from "../../model"; +import { AssetFactory } from "../../model/factories"; import * as resolvers from "./shared"; import { makeConnection } from "./util"; +const holdersResolver = async (root: Asset, args: any, ctx: IApolloContext, info: any) => { + const balances = await db.assets.findHolders(root, args); + + return makeConnection(balances); +}; + export default { - Asset: { issuer: resolvers.account }, - AssetWithInfo: { issuer: resolvers.account }, + Asset: { issuer: resolvers.account, balances: holdersResolver }, + AssetWithInfo: { issuer: resolvers.account, balances: holdersResolver }, Query: { + asset: async (root: any, args: { id: AssetID }, ctx: IApolloContext, info: any) => { + return AssetFactory.fromId(args.id); + }, assets: async (root: any, args: any, ctx: IApolloContext, info: any) => { const { code, issuer } = args; const records: IHorizonAssetData[] = await ctx.dataSources.assets.all( diff --git a/src/schema/resolvers/balance.ts b/src/schema/resolvers/balance.ts index 3bac396f..867c6dc0 100644 --- a/src/schema/resolvers/balance.ts +++ b/src/schema/resolvers/balance.ts @@ -1,7 +1,8 @@ import { withFilter } from "graphql-subscriptions"; import { IApolloContext } from "../../graphql_server"; -import { BalanceSubscriptionPayload } from "../../model"; +import { Balance, BalanceSubscriptionPayload } from "../../model"; import { BALANCE, pubsub } from "../../pubsub"; +import { toFloatAmountString } from "../../util/stellar"; import * as resolvers from "./shared"; import { eventMatches } from "./util"; @@ -24,7 +25,9 @@ export default { Balance: { account: resolvers.account, ledger: resolvers.ledger, - asset: resolvers.asset + asset: resolvers.asset, + limit: (root: Balance) => toFloatAmountString(root.limit), + balance: (root: Balance) => toFloatAmountString(root.balance) }, BalanceSubscriptionPayload: { account: resolvers.account, @@ -32,7 +35,9 @@ export default { }, BalanceValues: { account: resolvers.account, - asset: resolvers.asset + asset: resolvers.asset, + limit: (root: Balance) => toFloatAmountString(root.limit), + balance: (root: Balance) => toFloatAmountString(root.balance) }, Subscription: { balance: balanceSubscription(BALANCE) } }; diff --git a/src/schema/resolvers/util.ts b/src/schema/resolvers/util.ts index c96fdf6c..e192e737 100644 --- a/src/schema/resolvers/util.ts +++ b/src/schema/resolvers/util.ts @@ -22,10 +22,10 @@ export function idOnlyRequested(info: any): boolean { return false; } -export function makeConnection(records: T[], nodeBuilder: (r: T) => R) { +export function makeConnection(records: T[], nodeBuilder?: (r: T) => R) { const edges = records.map(record => { return { - node: nodeBuilder(record), + node: nodeBuilder ? nodeBuilder(record) : record, cursor: record.paging_token }; }); diff --git a/src/schema/type_defs.ts b/src/schema/type_defs.ts index fd88c5bf..5f71533c 100644 --- a/src/schema/type_defs.ts +++ b/src/schema/type_defs.ts @@ -72,6 +72,17 @@ export const typeDefs = gql` ledger: Ledger! } + type BalanceConnection { + pageInfo: PageInfo! + nodes: [Balance] + edges: [BalanceEdge] + } + + type BalanceEdge { + cursor: String! + node: Balance + } + "Represents a current [trustline](https://www.stellar.org/developers/guides/concepts/assets.html#trustlines) state, which is broadcasting to subscribers" type BalanceValues implements IBalance { account: Account diff --git a/src/util/paging.ts b/src/util/paging.ts index e648db50..ec17f54a 100644 --- a/src/util/paging.ts +++ b/src/util/paging.ts @@ -1,3 +1,5 @@ +import { PagingParams } from "../datasource/horizon/base"; + export enum SortOrder { DESC = "desc", ASC = "asc" @@ -10,3 +12,21 @@ export function invertSortOrder(order: SortOrder) { return SortOrder.DESC; } + +export function parseCursorPagination(args: PagingParams) { + const { first, after, last, before, order = SortOrder.DESC } = args; + + if (!first && !last) { + throw new Error("Missing paging parameters"); + } + + return { + limit: first || last, + order: last ? invertSortOrder(order) : order, + cursor: last ? before : after + }; +} + +export function properlyOrdered(records: any[], pagingParams: PagingParams): any[] { + return pagingParams.last ? records.reverse() : records; +} diff --git a/tests/unit/model/balance.test.ts b/tests/unit/model/balance.test.ts index 7a7863c9..51142754 100644 --- a/tests/unit/model/balance.test.ts +++ b/tests/unit/model/balance.test.ts @@ -2,7 +2,6 @@ import { Asset } from "stellar-sdk"; import { Balance } from "../../../src/model"; import { BalanceFactory } from "../../../src/model/factories"; import { MAX_INT64 } from "../../../src/util"; -import { toFloatAmountString } from "../../../src/util/stellar"; import AccountFactory from "../../factories/account"; const data = { @@ -25,8 +24,8 @@ describe("constructor", () => { it("sets account id", () => expect(subject.account).toEqual(data.accountid)); it("sets lastModified", () => expect(subject.lastModified).toEqual(data.lastmodified)); - it("formats limit", () => expect(subject.limit).toEqual("922337203685.4775807")); - it("formats balance", () => expect(subject.balance).toEqual("960.0000000")); + it("sets limit", () => expect(subject.limit).toEqual("9223372036854775807")); + it("sets balance", () => expect(subject.balance).toEqual("9600000000")); it("sets authorized", () => expect(subject.authorized).toBe(true)); it("sets asset", () => { expect(subject.asset).toBeInstanceOf(Asset); @@ -42,8 +41,8 @@ describe("static buildFakeNative(account)", () => { expect(fake).toMatchObject({ account: account.id, - balance: toFloatAmountString(account.balance), - limit: toFloatAmountString(MAX_INT64), + balance: account.balance, + limit: MAX_INT64, authorized: true, lastModified: account.lastModified });