diff --git a/subsquid/db/migrations/1665750215090-Data.js b/subsquid/db/migrations/1666263690230-Data.js similarity index 92% rename from subsquid/db/migrations/1665750215090-Data.js rename to subsquid/db/migrations/1666263690230-Data.js index bad130d5f01..fee0406803c 100644 --- a/subsquid/db/migrations/1665750215090-Data.js +++ b/subsquid/db/migrations/1666263690230-Data.js @@ -1,5 +1,5 @@ -module.exports = class Data1665750215090 { - name = 'Data1665750215090' +module.exports = class Data1666263690230 { + name = 'Data1666263690230' async up(db) { await db.query(`CREATE TABLE "account" ("id" character varying NOT NULL, "event_id" text NOT NULL, CONSTRAINT "PK_54115ee388cdb6d86bb4bf5b2ea" PRIMARY KEY ("id"))`) @@ -39,6 +39,8 @@ module.exports = class Data1665750215090 { await db.query(`CREATE UNIQUE INDEX "IDX_69e08176f6778a2a276720109d" ON "staking_position" ("fnft_collection_id", "fnft_instance_id") `) await db.query(`CREATE TABLE "historical_locked_value" ("id" character varying NOT NULL, "amount" numeric NOT NULL, "currency" character varying(3) NOT NULL, "timestamp" numeric NOT NULL, "source" character varying(16) NOT NULL, "event_id" character varying, CONSTRAINT "PK_39755ccbc61547e8b814bf28188" PRIMARY KEY ("id"))`) await db.query(`CREATE INDEX "IDX_e16f52796ccae0d99cb8d6e404" ON "historical_locked_value" ("event_id") `) + await db.query(`CREATE TABLE "historical_volume" ("id" character varying NOT NULL, "amount" numeric NOT NULL, "currency" character varying(3) NOT NULL, "timestamp" numeric NOT NULL, "asset_id" text NOT NULL, "event_id" character varying, CONSTRAINT "PK_7f5775a1b43be10057e93cad992" PRIMARY KEY ("id"))`) + await db.query(`CREATE INDEX "IDX_eceb392fe7bbe48cb21e1d8b5a" ON "historical_volume" ("event_id") `) await db.query(`ALTER TABLE "pablo_pool_asset" ADD CONSTRAINT "FK_7fd4cdb45620476d1de745a2658" FOREIGN KEY ("pool_id") REFERENCES "pablo_pool"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) await db.query(`ALTER TABLE "activity" ADD CONSTRAINT "FK_c2c1e9fdda754a6bf7f664d7e04" FOREIGN KEY ("event_id") REFERENCES "event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) await db.query(`ALTER TABLE "pablo_transaction" ADD CONSTRAINT "FK_0118a010cf1571fc5cb70b90a73" FOREIGN KEY ("event_id") REFERENCES "event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) @@ -46,6 +48,7 @@ module.exports = class Data1665750215090 { await db.query(`ALTER TABLE "historical_asset_price" ADD CONSTRAINT "FK_e5b6c7a8a991d63c9670391daaf" FOREIGN KEY ("asset_id") REFERENCES "asset"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) await db.query(`ALTER TABLE "staking_position" ADD CONSTRAINT "FK_3e2e1b465d89dbb2736e70fe5f1" FOREIGN KEY ("event_id") REFERENCES "event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) await db.query(`ALTER TABLE "historical_locked_value" ADD CONSTRAINT "FK_e16f52796ccae0d99cb8d6e4040" FOREIGN KEY ("event_id") REFERENCES "event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) + await db.query(`ALTER TABLE "historical_volume" ADD CONSTRAINT "FK_eceb392fe7bbe48cb21e1d8b5a5" FOREIGN KEY ("event_id") REFERENCES "event"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`) } async down(db) { @@ -86,6 +89,8 @@ module.exports = class Data1665750215090 { await db.query(`DROP INDEX "public"."IDX_69e08176f6778a2a276720109d"`) await db.query(`DROP TABLE "historical_locked_value"`) await db.query(`DROP INDEX "public"."IDX_e16f52796ccae0d99cb8d6e404"`) + await db.query(`DROP TABLE "historical_volume"`) + await db.query(`DROP INDEX "public"."IDX_eceb392fe7bbe48cb21e1d8b5a"`) await db.query(`ALTER TABLE "pablo_pool_asset" DROP CONSTRAINT "FK_7fd4cdb45620476d1de745a2658"`) await db.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_c2c1e9fdda754a6bf7f664d7e04"`) await db.query(`ALTER TABLE "pablo_transaction" DROP CONSTRAINT "FK_0118a010cf1571fc5cb70b90a73"`) @@ -93,5 +98,6 @@ module.exports = class Data1665750215090 { await db.query(`ALTER TABLE "historical_asset_price" DROP CONSTRAINT "FK_e5b6c7a8a991d63c9670391daaf"`) await db.query(`ALTER TABLE "staking_position" DROP CONSTRAINT "FK_3e2e1b465d89dbb2736e70fe5f1"`) await db.query(`ALTER TABLE "historical_locked_value" DROP CONSTRAINT "FK_e16f52796ccae0d99cb8d6e4040"`) + await db.query(`ALTER TABLE "historical_volume" DROP CONSTRAINT "FK_eceb392fe7bbe48cb21e1d8b5a5"`) } } diff --git a/subsquid/schema.graphql b/subsquid/schema.graphql index 41a1a787c7c..2b3459582f9 100644 --- a/subsquid/schema.graphql +++ b/subsquid/schema.graphql @@ -157,6 +157,15 @@ type HistoricalLockedValue @entity { source: LockedSource! } +type HistoricalVolume @entity { + id: ID! + event: Event! @index + amount: BigInt! + currency: Currency! + timestamp: BigInt! + assetId: String! +} + type PabloTransaction @entity { id: ID! event: Event! @unique @index diff --git a/subsquid/src/dbHelper.ts b/subsquid/src/dbHelper.ts index a3978a2c066..0ebb3990b79 100644 --- a/subsquid/src/dbHelper.ts +++ b/subsquid/src/dbHelper.ts @@ -12,6 +12,7 @@ import { Event, EventType, HistoricalLockedValue, + HistoricalVolume, LockedSource, PabloPool, } from "./model"; @@ -248,6 +249,60 @@ export async function storeHistoricalLockedValue( await ctx.store.save(historicalLockedValueSource); } +/** + * Stores a new HistoricalVolume for the specified quote asset + * @param ctx + * @param quoteAssetId + * @param amount + */ +export async function storeHistoricalVolume( + ctx: EventHandlerContext, + quoteAssetId: string, + amount: bigint +): Promise { + const wsProvider = new WsProvider(chain()); + const api = await ApiPromise.create({ provider: wsProvider }); + let assetPrice = 0n; + + try { + const oraclePrice = await api.query.oracle.prices(quoteAssetId); + console.log(oraclePrice); + if (!oraclePrice?.price) { + // TODO: handle missing oracle price + // NOTE: should we look at the latest known price for this asset? + return; + } + assetPrice = BigInt(oraclePrice.price.toString()); + } catch (error) { + console.error(error); + return; + } + + // TODO: get decimals for this asset + // NOTE: normalize to 12 decimals for historical values? + + const volume = amount * assetPrice; + + const lastVolume = await getLastVolume(ctx, quoteAssetId); + + const event = await ctx.store.get(Event, { where: { id: ctx.event.id } }); + + if (!event) { + return Promise.reject(new Error("Event not found")); + } + + const historicalVolume = new HistoricalVolume({ + id: randomUUID(), + event, + amount: lastVolume + volume, + currency: Currency.USD, + assetId: quoteAssetId, + timestamp: BigInt(new Date(ctx.block.timestamp).valueOf()), + }); + + await ctx.store.save(historicalVolume); +} + /** * Get latest locked value */ @@ -258,7 +313,24 @@ export async function getLastLockedValue( const lastLockedValue = await ctx.store.find(HistoricalLockedValue, { where: { source }, order: { timestamp: "DESC" }, + relations: { event: true }, }); return BigInt(lastLockedValue.length > 0 ? lastLockedValue[0].amount : 0); } + +/** + * Get latest volume + */ +export async function getLastVolume( + ctx: EventHandlerContext, + assetId: string +): Promise { + const lastVolume = await ctx.store.find(HistoricalVolume, { + where: { assetId }, + order: { timestamp: "DESC" }, + relations: { event: true }, + }); + + return BigInt(lastVolume.length > 0 ? lastVolume[0].amount : 0); +} diff --git a/subsquid/src/model/generated/historicalVolume.model.ts b/subsquid/src/model/generated/historicalVolume.model.ts new file mode 100644 index 00000000000..e5e29c212b6 --- /dev/null +++ b/subsquid/src/model/generated/historicalVolume.model.ts @@ -0,0 +1,30 @@ +import {Entity as Entity_, Column as Column_, PrimaryColumn as PrimaryColumn_, ManyToOne as ManyToOne_, Index as Index_} from "typeorm" +import * as marshal from "./marshal" +import {Event} from "./event.model" +import {Currency} from "./_currency" + +@Entity_() +export class HistoricalVolume { + constructor(props?: Partial) { + Object.assign(this, props) + } + + @PrimaryColumn_() + id!: string + + @Index_() + @ManyToOne_(() => Event, {nullable: true}) + event!: Event + + @Column_("numeric", {transformer: marshal.bigintTransformer, nullable: false}) + amount!: bigint + + @Column_("varchar", {length: 3, nullable: false}) + currency!: Currency + + @Column_("numeric", {transformer: marshal.bigintTransformer, nullable: false}) + timestamp!: bigint + + @Column_("text", {nullable: false}) + assetId!: string +} diff --git a/subsquid/src/model/generated/index.ts b/subsquid/src/model/generated/index.ts index 703a4063105..d932944a13d 100644 --- a/subsquid/src/model/generated/index.ts +++ b/subsquid/src/model/generated/index.ts @@ -13,6 +13,7 @@ export * from "./rewardPool.model" export * from "./stakingPosition.model" export * from "./_lockedSource" export * from "./historicalLockedValue.model" +export * from "./historicalVolume.model" export * from "./pabloTransaction.model" export * from "./event.model" export * from "./_eventType" diff --git a/subsquid/src/processors/pablo.ts b/subsquid/src/processors/pablo.ts index 3c1a6d19ab1..1fbe9791800 100644 --- a/subsquid/src/processors/pablo.ts +++ b/subsquid/src/processors/pablo.ts @@ -14,6 +14,7 @@ import { getLatestPoolByPoolId, getOrCreate, storeHistoricalLockedValue, + storeHistoricalVolume, } from "../dbHelper"; import { Event, @@ -118,7 +119,6 @@ export async function processPoolCreatedEvent( let tx = await ctx.store.get(Event, ctx.event.id); if (tx != undefined) { - console.log("qwe"); console.error("Unexpected event in db", tx); throw new Error("Unexpected event in db"); } @@ -551,6 +551,12 @@ export async function processSwappedEvent( await ctx.store.save(quoteAsset); await ctx.store.save(eventEntity); await ctx.store.save(pabloTransaction); + + await storeHistoricalVolume( + ctx, + quoteAsset.assetId, + swappedEvt.quoteAmount + ); } else { throw new Error("Pool not found"); } @@ -571,7 +577,6 @@ export async function processPoolDeletedEvent( ctx: EventHandlerContext, event: PabloPoolDeletedEvent ): Promise { - console.debug("processing LiquidityAddedEvent", ctx.event.id); const poolDeletedEvent = getPoolDeletedEvent(event); const pool = await getLatestPoolByPoolId(ctx.store, poolDeletedEvent.poolId); // only set values if the owner was missing, i.e a new pool diff --git a/subsquid/src/server-extension/resolvers/index.ts b/subsquid/src/server-extension/resolvers/index.ts index 3ebd6844473..cc18e3e7e9b 100644 --- a/subsquid/src/server-extension/resolvers/index.ts +++ b/subsquid/src/server-extension/resolvers/index.ts @@ -3,6 +3,7 @@ import { PicassoOverviewStatsResolver } from "./picassoOverviewStats"; import { PabloOverviewStatsResolver } from "./pabloOverviewStats"; import { AssetsResolver } from "./assets"; import { TotalValueLockedResolver } from "./totalValueLocked"; +import { TotalVolume } from "./totalVolume"; import { StakingRewardsStatsResolver } from "./stakingRewards"; export { @@ -11,5 +12,6 @@ export { PabloOverviewStatsResolver, AssetsResolver, TotalValueLockedResolver, + TotalVolume, StakingRewardsStatsResolver, }; diff --git a/subsquid/src/server-extension/resolvers/totalVolume.ts b/subsquid/src/server-extension/resolvers/totalVolume.ts new file mode 100644 index 00000000000..9cdfcc36c89 --- /dev/null +++ b/subsquid/src/server-extension/resolvers/totalVolume.ts @@ -0,0 +1,84 @@ +import { + Arg, + Field, + InputType, + Int, + ObjectType, + Query, + Resolver, +} from "type-graphql"; +import type { EntityManager } from "typeorm"; +import { IsDateString, Min } from "class-validator"; +import { HistoricalVolume } from "../../model"; +import { getTimelineParams } from "./common"; + +@ObjectType() +export class TotalVolume { + @Field(() => String, { nullable: false }) + date!: string; + + @Field(() => BigInt, { nullable: false }) + totalVolume!: bigint; + + constructor(props: Partial) { + Object.assign(this, props); + } +} + +@InputType() +export class TotalVolumeInput { + @Field(() => Int, { nullable: false }) + @Min(1) + intervalMinutes!: number; + + @Field(() => String, { nullable: true }) + @IsDateString() + dateFrom?: string; + + @Field(() => String, { nullable: true }) + @IsDateString() + dateTo?: string; +} + +@Resolver() +export class TotalVolumeResolver { + constructor(private tx: () => Promise) {} + + @Query(() => [TotalVolume]) + async totalVolume( + @Arg("params", { validate: true }) input: TotalVolumeInput + ): Promise { + const { intervalMinutes, dateFrom, dateTo } = input; + const { where, params } = getTimelineParams( + intervalMinutes, + dateFrom, + dateTo + ); + + const manager = await this.tx(); + + const rows: { + period: string; + total_volume: string; + }[] = await manager.getRepository(HistoricalVolume).query( + ` + SELECT + round(timestamp / $1) * $1 as period, + max(amount) as total_volume + FROM historical_volume + WHERE ${where.join(" AND ")} + GROUP BY period + ORDER BY period DESC + `, + params + ); + + return rows.map( + (row) => + new TotalVolume({ + date: new Date(parseInt(row.period, 10)).toISOString(), + totalVolume: BigInt(row.total_volume), + }) + ); + } +}