diff --git a/analytics/scripts/chain_activity_emitter_1d.flux b/analytics/scripts/chain_activity_emitter_1d.flux new file mode 100644 index 00000000..630290f9 --- /dev/null +++ b/analytics/scripts/chain_activity_emitter_1d.flux @@ -0,0 +1,40 @@ +import "date" + + +runTask = (start,stop,srcBucket,destBucket,destMeasurement) => { + + data = from(bucket: srcBucket) + |> range(start: start,stop: stop) + |> filter(fn: (r) => r._measurement == "vaa_volume_v2" and r.version == "v2") + |> filter(fn: (r) => r._field == "volume" and r._value > 0) + |> drop(columns:["destination_chain","app_id","token_chain","token_address","version","_measurement","_time"]) + |> rename(columns: {_start: "_time"}) + |> group(columns: ["emitter_chain","_time"]) + + vols = data + |> sum(column: "_value") + |> set(key: "_field", value: "volume") + |> set(key: "to", value: string(v:stop)) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) + + return data + |> count(column: "_value") + |> set(key: "_field", value: "count") + |> set(key: "to", value: string(v:stop)) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) +} + +bucketInfinite = "wormscan" +destMeasurement = "emitter_chain_activity_1d" + +stop = date.truncate(t: now(),unit: 1d) +start = date.sub(d: 1d, from: stop) + +option task = { + name: "calculate chain activity per emitter every day", + every: 1d, +} + +runTask(start:start, stop: stop, srcBucket: bucketInfinite, destBucket: bucketInfinite, destMeasurement: destMeasurement) \ No newline at end of file diff --git a/analytics/scripts/chain_activity_emitter_1h.flux b/analytics/scripts/chain_activity_emitter_1h.flux new file mode 100644 index 00000000..b61971a7 --- /dev/null +++ b/analytics/scripts/chain_activity_emitter_1h.flux @@ -0,0 +1,40 @@ +import "date" + + +runTask = (start,stop,srcBucket,destBucket,destMeasurement) => { + + data = from(bucket: srcBucket) + |> range(start: start,stop: stop) + |> filter(fn: (r) => r._measurement == "vaa_volume_v2" and r.version == "v2") + |> filter(fn: (r) => r._field == "volume" and r._value > 0) + |> drop(columns:["destination_chain","app_id","token_chain","token_address","version","_measurement","_time"]) + |> rename(columns: {_start: "_time"}) + |> group(columns: ["emitter_chain","_time"]) + + vols = data + |> sum(column: "_value") + |> set(key: "_field", value: "volume") + |> set(key: "to", value: string(v:stop)) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) + + return data + |> count(column: "_value") + |> set(key: "_field", value: "count") + |> set(key: "to", value: string(v:stop)) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) +} + +bucketInfinite = "wormscan" +destMeasurement = "emitter_chain_activity_1h" + +stop = date.truncate(t: now(),unit: 1h) +start = date.sub(d: 1h, from: stop) + +option task = { + name: "calculate chain activity per emitter every hour", + every: 1h, +} + +runTask(start:start, stop: stop, srcBucket: bucketInfinite, destBucket: bucketInfinite, destMeasurement: destMeasurement) \ No newline at end of file diff --git a/analytics/scripts/chain_activity_emitter_dest_1d.flux b/analytics/scripts/chain_activity_emitter_dest_1d.flux new file mode 100644 index 00000000..3a153cb8 --- /dev/null +++ b/analytics/scripts/chain_activity_emitter_dest_1d.flux @@ -0,0 +1,37 @@ +import "date" + +runTask = (start,stop,srcBucket,destBucket,destMeasurement) => { + data = from(bucket: srcBucket) + |> range(start: start,stop: stop) + |> filter(fn: (r) => r._measurement == "vaa_volume_v2" and r._field == "volume") + |> group(columns: ["emitter_chain", "destination_chain", "app_id"]) + + data + |> sum(column: "_value") + |> set(key: "_field", value: "volume") + |> map(fn: (r) => ({ r with _time: start })) + |> set(key: "to", value: string(v:date.add(d: 1d, to: start))) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) + + return data + |> count(column: "_value") + |> set(key: "_field", value: "count") + |> map(fn: (r) => ({ r with _time: start })) + |> set(key: "to", value: string(v:date.add(d: 1d, to: start))) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) +} + + +bucketInfinite = "wormscan" +destMeasurement = "chain_activity_1d" +stop = date.truncate(t: now(),unit: 24h) +start = date.sub(d: 1d, from: stop) + +option task = { + name: "calculate chain activity every day", + every: 1d, +} + +runTask(start:start, stop: stop, srcBucket: bucketInfinite, destBucket: bucketInfinite, destMeasurement: destMeasurement) \ No newline at end of file diff --git a/analytics/scripts/chain_activity_emitter_dest_1h.flux b/analytics/scripts/chain_activity_emitter_dest_1h.flux new file mode 100644 index 00000000..256402b4 --- /dev/null +++ b/analytics/scripts/chain_activity_emitter_dest_1h.flux @@ -0,0 +1,40 @@ +import "date" + + +runTask = (start,stop,srcBucket,destBucket,destMeasurement) => { + + data = from(bucket: srcBucket) + |> range(start: start,stop: stop) + |> filter(fn: (r) => r._measurement == "vaa_volume_v2" and r._field == "volume") + |> group(columns: ["emitter_chain", "destination_chain", "app_id"]) + + data + |> sum(column: "_value") + |> set(key: "_field", value: "volume") + |> map(fn: (r) => ({ r with _time: start })) + |> set(key: "to", value: string(v:date.add(d: 1h, to: start))) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) + + return data + |> count(column: "_value") + |> set(key: "_field", value: "count") + |> map(fn: (r) => ({ r with _time: start })) + |> set(key: "to", value: string(v:date.add(d: 1h, to: start))) + |> set(key: "_measurement", value: destMeasurement) + |> to(bucket: destBucket) +} + + +bucketInfinite = "wormscan" +destMeasurement = "chain_activity_1h" + +stop = date.truncate(t: now(),unit: 1h) +start = date.sub(d: 1h, from: stop) + +option task = { + name: "calculate chain activity every hour", + every: 1h, +} + +runTask(start:start, stop: stop, srcBucket: bucketInfinite, destBucket: bucketInfinite, destMeasurement: destMeasurement) \ No newline at end of file diff --git a/api/docs/docs.go b/api/docs/docs.go index 45c1646a..d8c0ff52 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1760,6 +1760,73 @@ const docTemplate = `{ } } }, + "/api/v1/x-chain-activity/tops": { + "get": { + "description": "Search, for a specific period of time, the number of transactions and the volume.", + "tags": [ + "wormholescan" + ], + "operationId": "x-chain-activity-tops", + "parameters": [ + { + "type": "string", + "description": "Time span, supported values: 1d, 1mo and 1y", + "name": "timespan", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "From date, supported format 2006-01-02T15:04:05Z07:00", + "name": "from", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "To date, supported format 2006-01-02T15:04:05Z07:00", + "name": "to", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Search by appId", + "name": "appId", + "in": "query" + }, + { + "type": "string", + "description": "Search by sourceChain", + "name": "sourceChain", + "in": "query" + }, + { + "type": "string", + "description": "Search by targetChain", + "name": "targetChain", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/transactions.ChainActivityTopResult" + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/swagger.json": { "get": { "description": "Returns the swagger specification for this API.", @@ -3325,6 +3392,29 @@ const docTemplate = `{ } } }, + "transactions.ChainActivityTopResult": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "destination_chain": { + "type": "string" + }, + "emitter_chain": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "volume": { + "type": "integer" + } + } + }, "transactions.ChainPair": { "type": "object", "properties": { @@ -3591,7 +3681,6 @@ const docTemplate = `{ 14, 15, 16, - 17, 18, 19, 20, @@ -3599,24 +3688,35 @@ const docTemplate = `{ 22, 23, 24, + 25, 26, 28, 29, 30, 32, + 33, 34, 35, + 36, + 37, + 38, + 39, 3104, 4000, 4001, 4002, 4003, 4004, + 4005, + 4006, + 4007, + 4008, 10002, 10003, 10004, 10005, - 10006 + 10006, + 10007 ], "x-enum-varnames": [ "ChainIDUnset", @@ -3636,7 +3736,6 @@ const docTemplate = `{ "ChainIDCelo", "ChainIDNear", "ChainIDMoonbeam", - "ChainIDNeon", "ChainIDTerra2", "ChainIDInjective", "ChainIDOsmosis", @@ -3644,24 +3743,35 @@ const docTemplate = `{ "ChainIDAptos", "ChainIDArbitrum", "ChainIDOptimism", + "ChainIDGnosis", "ChainIDPythNet", "ChainIDXpla", "ChainIDBtc", "ChainIDBase", "ChainIDSei", + "ChainIDRootstock", "ChainIDScroll", "ChainIDMantle", + "ChainIDBlast", + "ChainIDXLayer", + "ChainIDLinea", + "ChainIDBerachain", "ChainIDWormchain", "ChainIDCosmoshub", "ChainIDEvmos", "ChainIDKujira", "ChainIDNeutron", "ChainIDCelestia", + "ChainIDStargaze", + "ChainIDSeda", + "ChainIDDymension", + "ChainIDProvenance", "ChainIDSepolia", "ChainIDArbitrumSepolia", "ChainIDBaseSepolia", "ChainIDOptimismSepolia", - "ChainIDHolesky" + "ChainIDHolesky", + "ChainIDPolygonSepolia" ] }, "vaa.VaaDoc": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index cf92f224..e6882765 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1753,6 +1753,73 @@ } } }, + "/api/v1/x-chain-activity/tops": { + "get": { + "description": "Search, for a specific period of time, the number of transactions and the volume.", + "tags": [ + "wormholescan" + ], + "operationId": "x-chain-activity-tops", + "parameters": [ + { + "type": "string", + "description": "Time span, supported values: 1d, 1mo and 1y", + "name": "timespan", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "From date, supported format 2006-01-02T15:04:05Z07:00", + "name": "from", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "To date, supported format 2006-01-02T15:04:05Z07:00", + "name": "to", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Search by appId", + "name": "appId", + "in": "query" + }, + { + "type": "string", + "description": "Search by sourceChain", + "name": "sourceChain", + "in": "query" + }, + { + "type": "string", + "description": "Search by targetChain", + "name": "targetChain", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/transactions.ChainActivityTopResult" + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/swagger.json": { "get": { "description": "Returns the swagger specification for this API.", @@ -3318,6 +3385,29 @@ } } }, + "transactions.ChainActivityTopResult": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "destination_chain": { + "type": "string" + }, + "emitter_chain": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "volume": { + "type": "integer" + } + } + }, "transactions.ChainPair": { "type": "object", "properties": { @@ -3584,7 +3674,6 @@ 14, 15, 16, - 17, 18, 19, 20, @@ -3592,24 +3681,35 @@ 22, 23, 24, + 25, 26, 28, 29, 30, 32, + 33, 34, 35, + 36, + 37, + 38, + 39, 3104, 4000, 4001, 4002, 4003, 4004, + 4005, + 4006, + 4007, + 4008, 10002, 10003, 10004, 10005, - 10006 + 10006, + 10007 ], "x-enum-varnames": [ "ChainIDUnset", @@ -3629,7 +3729,6 @@ "ChainIDCelo", "ChainIDNear", "ChainIDMoonbeam", - "ChainIDNeon", "ChainIDTerra2", "ChainIDInjective", "ChainIDOsmosis", @@ -3637,24 +3736,35 @@ "ChainIDAptos", "ChainIDArbitrum", "ChainIDOptimism", + "ChainIDGnosis", "ChainIDPythNet", "ChainIDXpla", "ChainIDBtc", "ChainIDBase", "ChainIDSei", + "ChainIDRootstock", "ChainIDScroll", "ChainIDMantle", + "ChainIDBlast", + "ChainIDXLayer", + "ChainIDLinea", + "ChainIDBerachain", "ChainIDWormchain", "ChainIDCosmoshub", "ChainIDEvmos", "ChainIDKujira", "ChainIDNeutron", "ChainIDCelestia", + "ChainIDStargaze", + "ChainIDSeda", + "ChainIDDymension", + "ChainIDProvenance", "ChainIDSepolia", "ChainIDArbitrumSepolia", "ChainIDBaseSepolia", "ChainIDOptimismSepolia", - "ChainIDHolesky" + "ChainIDHolesky", + "ChainIDPolygonSepolia" ] }, "vaa.VaaDoc": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 02ef4289..ae8cece6 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -824,6 +824,21 @@ definitions: $ref: '#/definitions/transactions.Tx' type: array type: object + transactions.ChainActivityTopResult: + properties: + count: + type: integer + destination_chain: + type: string + emitter_chain: + type: string + from: + type: string + to: + type: string + volume: + type: integer + type: object transactions.ChainPair: properties: destinationChain: @@ -1012,7 +1027,6 @@ definitions: - 14 - 15 - 16 - - 17 - 18 - 19 - 20 @@ -1020,24 +1034,35 @@ definitions: - 22 - 23 - 24 + - 25 - 26 - 28 - 29 - 30 - 32 + - 33 - 34 - 35 + - 36 + - 37 + - 38 + - 39 - 3104 - 4000 - 4001 - 4002 - 4003 - 4004 + - 4005 + - 4006 + - 4007 + - 4008 - 10002 - 10003 - 10004 - 10005 - 10006 + - 10007 type: integer x-enum-varnames: - ChainIDUnset @@ -1057,7 +1082,6 @@ definitions: - ChainIDCelo - ChainIDNear - ChainIDMoonbeam - - ChainIDNeon - ChainIDTerra2 - ChainIDInjective - ChainIDOsmosis @@ -1065,24 +1089,35 @@ definitions: - ChainIDAptos - ChainIDArbitrum - ChainIDOptimism + - ChainIDGnosis - ChainIDPythNet - ChainIDXpla - ChainIDBtc - ChainIDBase - ChainIDSei + - ChainIDRootstock - ChainIDScroll - ChainIDMantle + - ChainIDBlast + - ChainIDXLayer + - ChainIDLinea + - ChainIDBerachain - ChainIDWormchain - ChainIDCosmoshub - ChainIDEvmos - ChainIDKujira - ChainIDNeutron - ChainIDCelestia + - ChainIDStargaze + - ChainIDSeda + - ChainIDDymension + - ChainIDProvenance - ChainIDSepolia - ChainIDArbitrumSepolia - ChainIDBaseSepolia - ChainIDOptimismSepolia - ChainIDHolesky + - ChainIDPolygonSepolia vaa.VaaDoc: properties: appId: @@ -2326,6 +2361,52 @@ paths: description: Internal Server Error tags: - wormholescan + /api/v1/x-chain-activity/tops: + get: + description: Search, for a specific period of time, the number of transactions + and the volume. + operationId: x-chain-activity-tops + parameters: + - description: 'Time span, supported values: 1d, 1mo and 1y' + in: query + name: timespan + required: true + type: string + - description: From date, supported format 2006-01-02T15:04:05Z07:00 + in: query + name: from + required: true + type: string + - description: To date, supported format 2006-01-02T15:04:05Z07:00 + in: query + name: to + required: true + type: string + - description: Search by appId + in: query + name: appId + type: string + - description: Search by sourceChain + in: query + name: sourceChain + type: string + - description: Search by targetChain + in: query + name: targetChain + type: string + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/transactions.ChainActivityTopResult' + type: array + "400": + description: Bad Request + "500": + description: Internal Server Error + tags: + - wormholescan /swagger.json: get: description: Returns the swagger specification for this API. diff --git a/api/handlers/transactions/model.go b/api/handlers/transactions/model.go index 2a8a90c6..adc3beee 100644 --- a/api/handlers/transactions/model.go +++ b/api/handlers/transactions/model.go @@ -143,6 +143,17 @@ type ChainActivityResult struct { Volume uint64 `mapstructure:"_value" json:"volume"` } +type ChainActivityTopResult struct { + Time time.Time `json:"from" mapstructure:"_time"` + To string `json:"to" mapstructure:"to"` + ChainSourceID string `mapstructure:"emitter_chain" json:"emitter_chain"` + ChainDestinationID string `mapstructure:"destination_chain" json:"destination_chain,omitempty"` + Volume uint64 `mapstructure:"volume" json:"volume"` + Txs uint64 `mapstructure:"count" json:"count"` +} + +type ChainActivityTopResults []ChainActivityTopResult + type ChainActivityTimeSpan string const ( @@ -202,3 +213,25 @@ type TransactionDto struct { Payload map[string]interface{} `bson:"payload"` StandardizedProperties map[string]interface{} `bson:"standardizedProperties"` } + +type ChainActivityTopsQuery struct { + SourceChain *sdk.ChainID `json:"source_chain"` + TargetChain *sdk.ChainID `json:"target_chain"` + AppId string `json:"app_id"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Timespan Timespan `json:"timespan"` +} + +type Timespan string + +const ( + Hour Timespan = "1h" + Day Timespan = "1d" + Month Timespan = "1mo" + Year Timespan = "1y" +) + +func (t Timespan) IsValid() bool { + return t == Hour || t == Day || t == Month || t == Year +} diff --git a/api/handlers/transactions/repository.go b/api/handlers/transactions/repository.go index 37a189bf..fffb7201 100644 --- a/api/handlers/transactions/repository.go +++ b/api/handlers/transactions/repository.go @@ -3,6 +3,7 @@ package transactions import ( "context" "fmt" + "github.com/valyala/fasthttp" "strconv" "strings" "sync" @@ -1048,3 +1049,338 @@ func (r *Repository) ListTransactionsByAddress( return documents, nil } + +func (r *Repository) FindChainActivityTops(ctx *fasthttp.RequestCtx, q ChainActivityTopsQuery) ([]ChainActivityTopResult, error) { + query := r.buildChainActivityQueryTops(q) + result, err := r.queryAPI.Query(ctx, query) + if err != nil { + return nil, err + } + if result.Err() != nil { + return nil, result.Err() + } + var response []ChainActivityTopResult + for result.Next() { + var row ChainActivityTopResult + if err = mapstructure.Decode(result.Record().Values(), &row); err != nil { + return nil, err + } + parsedTime, errTime := time.Parse(time.RFC3339Nano, row.To) + if errTime == nil { + row.To = parsedTime.Format(time.RFC3339) + } + response = append(response, row) + } + + return response, nil +} + +func (r *Repository) buildChainActivityQueryTops(q ChainActivityTopsQuery) string { + + var start, stop string + + switch q.Timespan { + case Hour: + start = q.From.Truncate(1 * time.Hour).UTC().Format(time.RFC3339) + stop = q.To.Truncate(1 * time.Hour).UTC().Format(time.RFC3339) + case Day: + start = q.From.Truncate(24 * time.Hour).UTC().Format(time.RFC3339) + stop = q.To.Truncate(24 * time.Hour).UTC().Format(time.RFC3339) + case Month: + start = time.Date(q.From.Year(), q.From.Month(), 1, 0, 0, 0, 0, q.From.Location()).UTC().Format(time.RFC3339) + stop = time.Date(q.To.Year(), q.To.Month(), 1, 0, 0, 0, 0, q.To.Location()).UTC().Format(time.RFC3339) + default: + start = time.Date(q.From.Year(), 1, 1, 0, 0, 0, 0, q.From.Location()).UTC().Format(time.RFC3339) + stop = time.Date(q.To.Year(), 1, 1, 0, 0, 0, 0, q.To.Location()).UTC().Format(time.RFC3339) + } + + filterTargetChain := "" + if q.TargetChain != nil { + filterTargetChain = "|> filter(fn: (r) => r.destination_chain == \"" + strconv.Itoa(int(*q.TargetChain)) + "\")" + } + + filterSourceChain := "" + if q.SourceChain != nil { + filterSourceChain = "|> filter(fn: (r) => r.emitter_chain == \"" + strconv.Itoa(int(*q.SourceChain)) + "\")" + } + + filterAppId := "" + if q.AppId != "" { + filterAppId = "|> filter(fn: (r) => r.app_id == \"" + q.AppId + "\")" + } + + if q.TargetChain == nil && q.AppId == "" { + return r.buildQueryChainActivityTopsByEmitter(q, start, stop, filterSourceChain) + } + + var query string + switch q.Timespan { + case Hour: + query = r.buildQueryChainActivityHourly(start, stop, filterSourceChain, filterTargetChain, filterAppId) + case Day: + query = r.buildQueryChainActivityDaily(start, stop, filterSourceChain, filterTargetChain, filterAppId) + case Month: + query = r.buildQueryChainActivityMonthly(start, stop, filterSourceChain, filterTargetChain, filterAppId) + default: + query = r.buildQueryChainActivityYearly(start, stop, filterSourceChain, filterTargetChain, filterAppId) + } + return query +} + +func (r *Repository) buildQueryChainActivityTopsByEmitter(q ChainActivityTopsQuery, start, stop, filterSourceChain string) string { + + measurement := "" + switch q.Timespan { + case Hour: + measurement = "emitter_chain_activity_1h" + default: + measurement = "emitter_chain_activity_1d" + } + + if q.Timespan == Hour || q.Timespan == Day { + query := ` + import "date" + + from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "%s") + %s + |> pivot(rowKey:["_time","emitter_chain"], columnKey: ["_field"], valueColumn: "_value") + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, measurement, filterSourceChain) + } + + if q.Timespan == Month { + query := ` + import "date" + import "join" + + data = from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "%s") + %s + |> drop(columns:["to"]) + |> window(every: 1mo, period:1mo) + |> drop(columns:["_time"]) + |> rename(columns: {_start: "_time"}) + |> map(fn: (r) => ({r with to: string(v: r._stop)})) + + vols = data + |> filter(fn: (r) => (r._field == "volume" and r._value > 0)) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "volume"}) + + counts = data + |> filter(fn: (r) => (r._field == "count")) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "count"}) + + join.inner( + left: vols, + right: counts, + on: (l, r) => l._time == r._time and l.emitter_chain == r.emitter_chain, + as: (l, r) => ({l with count: r.count}), + ) + |> group() + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, measurement, filterSourceChain) + } + + query := ` + import "date" + import "join" + + data = from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "%s") + %s + |> drop(columns:["to"]) + |> window(every: 1y, period:1y) + |> drop(columns:["_time"]) + |> rename(columns: {_start: "_time"}) + |> map(fn: (r) => ({r with to: string(v: r._stop)})) + + vols = data + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "volume"}) + + counts = data + |> filter(fn: (r) => (r._field == "count")) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "count"}) + + join.inner( + left: vols, + right: counts, + on: (l, r) => l._time == r._time and l.emitter_chain == r.emitter_chain, + as: (l, r) => ({l with count: r.count}), + ) + |> group() + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, measurement, filterSourceChain) + +} + +func (r *Repository) buildQueryChainActivityHourly(start, stop, filterSourceChain, filterTargetChain, filterAppId string) string { + query := ` + import "date" + import "join" + + data = from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "chain_activity_1h") + %s + %s + %s + |> drop(columns:["destination_chain"]) + + vols = data + |> filter(fn: (r) => (r._field == "volume" and r._value > 0)) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "volume"}) + + counts = data + |> filter(fn: (r) => (r._field == "count")) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "count"}) + + join.inner( + left: vols, + right: counts, + on: (l, r) => l._time == r._time and l.to == r.to and l.emitter_chain == r.emitter_chain, + as: (l, r) => ({l with count: r.count}), + ) + |> group() + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, filterSourceChain, filterTargetChain, filterAppId) +} + +func (r *Repository) buildQueryChainActivityDaily(start, stop, filterSourceChain, filterTargetChain, filterAppId string) string { + + query := ` + import "date" + import "join" + + data = from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "chain_activity_1d") + %s + %s + %s + |> drop(columns:["destination_chain"]) + + vols = data + |> filter(fn: (r) => (r._field == "volume" and r._value > 0)) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "volume"}) + + counts = data + |> filter(fn: (r) => (r._field == "count")) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "count"}) + + join.inner( + left: vols, + right: counts, + on: (l, r) => l._time == r._time and l.to == r.to and l.emitter_chain == r.emitter_chain, + as: (l, r) => ({l with count: r.count}), + ) + |> group() + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, filterSourceChain, filterTargetChain, filterAppId) +} + +func (r *Repository) buildQueryChainActivityMonthly(start, stop, filterSourceChain, filterTargetChain, filterAppId string) string { + query := ` + import "date" + import "join" + + data = from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "chain_activity_1d") + %s + %s + %s + |> drop(columns:["destination_chain","to","app_id"]) + |> window(every: 1mo, period:1mo) + |> drop(columns:["_time"]) + |> rename(columns: {_start: "_time"}) + |> map(fn: (r) => ({r with to: string(v: r._stop)})) + + vols = data + |> filter(fn: (r) => (r._field == "volume" and r._value > 0)) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "volume"}) + + counts = data + |> filter(fn: (r) => (r._field == "count")) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "count"}) + + join.inner( + left: vols, + right: counts, + on: (l, r) => l._time == r._time and l.emitter_chain == r.emitter_chain, + as: (l, r) => ({l with count: r.count}), + ) + |> group() + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, filterSourceChain, filterTargetChain, filterAppId) +} + +func (r *Repository) buildQueryChainActivityYearly(start, stop, filterSourceChain, filterTargetChain, filterAppId string) string { + query := ` + import "date" + import "join" + + data = from(bucket: "%s") + |> range(start: %s,stop: %s) + |> filter(fn: (r) => r._measurement == "chain_activity_1d") + %s + %s + %s + |> drop(columns:["destination_chain","to","app_id"]) + |> window(every: 1y, period:1y) + |> drop(columns:["_time"]) + |> rename(columns: {_start: "_time"}) + |> map(fn: (r) => ({r with to: string(v: r._stop)})) + + vols = data + |> filter(fn: (r) => (r._field == "volume" and r._value > 0)) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "volume"}) + + counts = data + |> filter(fn: (r) => (r._field == "count")) + |> group(columns:["_time","to","emitter_chain"]) + |> sum() + |> rename(columns: {_value: "count"}) + + join.inner( + left: vols, + right: counts, + on: (l, r) => l._time == r._time and l.emitter_chain == r.emitter_chain, + as: (l, r) => ({l with count: r.count}), + ) + |> group() + |> sort(columns:["emitter_chain","_time"],desc:false) + ` + return fmt.Sprintf(query, r.bucketInfiniteRetention, start, stop, filterSourceChain, filterTargetChain, filterAppId) +} diff --git a/api/handlers/transactions/service.go b/api/handlers/transactions/service.go index b08d5c0d..6e6f4ca9 100644 --- a/api/handlers/transactions/service.go +++ b/api/handlers/transactions/service.go @@ -2,12 +2,13 @@ package transactions import ( "context" + errors "errors" "fmt" + "github.com/valyala/fasthttp" "strings" "time" "github.com/wormhole-foundation/wormhole-explorer/api/cacheable" - "github.com/wormhole-foundation/wormhole-explorer/api/internal/errors" errs "github.com/wormhole-foundation/wormhole-explorer/api/internal/errors" "github.com/wormhole-foundation/wormhole-explorer/api/internal/metrics" "github.com/wormhole-foundation/wormhole-explorer/api/internal/pagination" @@ -34,6 +35,7 @@ const ( topAssetsByVolumeKey = "wormscan:top-assets-by-volume" topChainPairsByNumTransfersKey = "wormscan:top-chain-pairs-by-num-transfers" chainActivityKey = "wormscan:chain-activity" + chainActivityTopsKey = "wormscan:chain-activity-tops" ) // NewService create a new Service. @@ -157,7 +159,7 @@ func (s *Service) GetTransactionByID( return nil, err } if len(output) == 0 { - return nil, errors.ErrNotFound + return nil, errs.ErrNotFound } // Return matching document @@ -167,3 +169,44 @@ func (s *Service) GetTransactionByID( func (s *Service) GetTokenProvider() *domain.TokenProvider { return s.tokenProvider } + +func (s *Service) GetChainActivityTops(ctx *fasthttp.RequestCtx, q ChainActivityTopsQuery) (ChainActivityTopResults, error) { + + timeDuration := q.To.Sub(q.From) + + if q.Timespan == Hour && timeDuration > 15*24*time.Hour { + return nil, errors.New("time range is too large for hourly data. Max time range allowed: 15 days") + } + + if q.Timespan == Day { + if timeDuration < 24*time.Hour { + return nil, errors.New("time range is too small for daily data. Min time range allowed: 2 day") + } + + if timeDuration > 365*24*time.Hour { + return nil, errors.New("time range is too large for daily data. Max time range allowed: 1 year") + } + } + + if q.Timespan == Month { + if timeDuration < 30*24*time.Hour { + return nil, errors.New("time range is too small for monthly data. Min time range allowed: 60 days") + } + + if timeDuration > 10*365*24*time.Hour { + return nil, errors.New("time range is too large for monthly data. Max time range allowed: 1 year") + } + } + + if q.Timespan == Year { + if timeDuration < 365*24*time.Hour { + return nil, errors.New("time range is too small for yearly data. Min time range allowed: 1 year") + } + + if timeDuration > 10*365*24*time.Hour { + return nil, errors.New("time range is too large for yearly data. Max time range allowed: 10 year") + } + } + + return s.repo.FindChainActivityTops(ctx, q) +} diff --git a/api/middleware/extract_parameters.go b/api/middleware/extract_parameters.go index 1e5b5b50..0ba910a1 100644 --- a/api/middleware/extract_parameters.go +++ b/api/middleware/extract_parameters.go @@ -61,10 +61,6 @@ func ExtractToChain(c *fiber.Ctx, l *zap.Logger) (*sdk.ChainID, error) { return &result, nil } -func ExtractChain(c *fiber.Ctx, l *zap.Logger) (*sdk.ChainID, error) { - return extractChainQueryParam(c, l, "chain") -} - func ExtractSourceChain(c *fiber.Ctx, l *zap.Logger) (*sdk.ChainID, error) { return extractChainQueryParam(c, l, "sourceChain") } @@ -74,12 +70,10 @@ func ExtractTargetChain(c *fiber.Ctx, l *zap.Logger) (*sdk.ChainID, error) { } func extractChainQueryParam(c *fiber.Ctx, l *zap.Logger, queryParam string) (*sdk.ChainID, error) { - param := c.Query(queryParam) if param == "" { return nil, nil } - chain, err := strconv.ParseInt(param, 10, 16) if err != nil { requestID := fmt.Sprintf("%v", c.Locals("requestid")) @@ -90,7 +84,6 @@ func extractChainQueryParam(c *fiber.Ctx, l *zap.Logger, queryParam string) (*sd return nil, response.NewInvalidParamError(c, "INVALID CHAIN VALUE", errors.WithStack(err)) } - result := sdk.ChainID(chain) return &result, nil } @@ -358,14 +351,13 @@ func ExtractTimeSpanAndSampleRate(c *fiber.Ctx, l *zap.Logger) (string, string, return timeSpan, sampleRate, nil } -func ExtractTime(c *fiber.Ctx, queryParam string) (*time.Time, error) { +func ExtractTime(c *fiber.Ctx, timeLayout, queryParam string) (*time.Time, error) { // get the start_time from query params date := c.Query(queryParam, "") if date == "" { return nil, nil } - - t, err := time.Parse("20060102T150405Z", date) + t, err := time.Parse(timeLayout, date) if err != nil { return nil, response.NewInvalidQueryParamError(c, fmt.Sprintf("INVALID <%s> QUERY PARAMETER", queryParam), nil) } diff --git a/api/routes/wormscan/routes.go b/api/routes/wormscan/routes.go index 1d10b94a..00c5a77c 100644 --- a/api/routes/wormscan/routes.go +++ b/api/routes/wormscan/routes.go @@ -85,6 +85,7 @@ func RegisterRoutes( api.Get("/last-txs", transactionCtrl.GetLastTransactions) api.Get("/scorecards", transactionCtrl.GetScorecards) api.Get("/x-chain-activity", transactionCtrl.GetChainActivity) + api.Get("/x-chain-activity/tops", transactionCtrl.GetChainActivityTops) api.Get("/top-assets-by-volume", transactionCtrl.GetTopAssets) api.Get("/top-chain-pairs-by-num-transfers", transactionCtrl.GetTopChainPairs) api.Get("token/:chain/:token_address", transactionCtrl.GetTokenByChainAndAddress) diff --git a/api/routes/wormscan/transactions/controller.go b/api/routes/wormscan/transactions/controller.go index 06390eba..22b7a51f 100644 --- a/api/routes/wormscan/transactions/controller.go +++ b/api/routes/wormscan/transactions/controller.go @@ -2,6 +2,7 @@ package transactions import ( "strconv" + "time" "github.com/gofiber/fiber/v2" "github.com/shopspring/decimal" @@ -182,6 +183,75 @@ func (c *Controller) GetTopAssets(ctx *fiber.Ctx) error { return ctx.JSON(response) } +// GetChainActivityTops godoc +// @Description Search for a specific period of time the number of transactions and the volume. +// @Tags wormholescan +// @ID x-chain-activity-tops +// @Method Get +// @Param timespan query string true "Time span, supported values: 1d, 1mo and 1y" +// @Param from query string true "From date, supported format 2006-01-02T15:04:05Z07:00" +// @Param to query string true "To date, supported format 2006-01-02T15:04:05Z07:00" +// @Param appId query string false "Search by appId" +// @Param sourceChain query string false "Search by sourceChain" +// @Param targetChain query string false "Search by targetChain" +// @Success 200 {object} transactions.ChainActivityTopResults +// @Failure 400 +// @Failure 500 +// @Router /api/v1/x-chain-activity/tops [get] +func (c *Controller) GetChainActivityTops(ctx *fiber.Ctx) error { + + sourceChain, err := middleware.ExtractSourceChain(ctx, c.logger) + if err != nil { + return err + } + targetChain, err := middleware.ExtractTargetChain(ctx, c.logger) + if err != nil { + return err + } + from, err := middleware.ExtractTime(ctx, time.RFC3339, "from") + if err != nil { + return err + } + to, err := middleware.ExtractTime(ctx, time.RFC3339, "to") + if err != nil { + return err + } + if from == nil || to == nil { + return response.NewInvalidParamError(ctx, "missing from/to query params ", nil) + } + + payload := transactions.ChainActivityTopsQuery{ + SourceChain: sourceChain, + TargetChain: targetChain, + From: *from, + To: *to, + AppId: middleware.ExtractAppId(ctx, c.logger), + Timespan: transactions.Timespan(ctx.Query("timespan")), + } + + if !payload.Timespan.IsValid() { + return response.NewInvalidParamError(ctx, "invalid timespan", nil) + } + + nowUTC := time.Now().UTC() + if nowUTC.Before(payload.To.UTC()) { + payload.To = nowUTC + } + + if payload.To.Sub(payload.From) <= 0 { + return response.NewInvalidParamError(ctx, "invalid time range", nil) + } + + // Get the chain activity. + activity, err := c.srv.GetChainActivityTops(ctx.Context(), payload) + if err != nil { + c.logger.Error("Error getting chain activity", zap.Error(err)) + return err + } + + return ctx.JSON(activity) +} + // GetChainActivity godoc // @Description Returns a list of chain pairs by origin chain and destination chain. // @Description The list could be rendered by notional or transaction count.