From a0475ab17e4426a1eefbc0eb6f53b536fc692699 Mon Sep 17 00:00:00 2001 From: agodnic Date: Mon, 12 Jun 2023 11:43:48 -0300 Subject: [PATCH] Add endpoint `GET /api/v1/transactions` (#388) ### Summary Tracking issue: https://github.com/wormhole-foundation/wormhole-explorer/issues/385 This pull request implements a new endpoint, `GET /api/v1/transactions`, which will be consumed by the wormhole explorer UI. The endpoint returns a paginated list of transactions, in which each element contains a brief overview of the transaction (ID, txHash, status, etc.). It exposes offset-based pagination via the parameters `page` and `pageSize`. Also, results can be obtained for a specific address by using the `address` query parameter. The response model looks like this: ```json { "transactions": [ { "id": "1/5ec18c34b47c63d17ab43b07b9b2319ea5ee2d163bce2e467000174e238c8e7f/12965", "timestamp": "2023-06-08T19:30:19Z", "txHash": "a302c4ab2d6b9a6003951d2e91f8fdbb83cfa20f6ffb588b95ef0290aab37066", "originChain": 1, "status": "ongoing" }, { "id": "22/0000000000000000000000000000000000000000000000000000000000000001/18308", "timestamp": "2023-06-08T19:17:14Z", "txHash": "00000000000000000000000000000000000000000000000000000000000047e7", "originChain": 22, "destinationAddress": "0x00000000000000000000000067e8a40816a983fbe3294aaebd0cc2391815b86b", "destinationChain": 5, "tokenAmount": "0.12", "usdAmount": "0.12012", "symbol": "USDC", "status": "completed" }, ... ] } ``` ### Limitations of the current implementation 1. Doesn't return the total number of results (this may result in a performance issue when we filter by address) 2. Can only filter by receiver address (we don't have sender information in the database yet) --- api/docs/docs.go | 175 +++++++++-- api/docs/swagger.json | 170 +++++++++-- api/docs/swagger.yaml | 110 +++++-- api/handlers/transactions/repository.go | 273 +++++++++++++++++- api/handlers/transactions/service.go | 18 ++ api/middleware/extract_parameters.go | 28 +- api/routes/wormscan/address/controller.go | 2 +- api/routes/wormscan/routes.go | 3 +- .../wormscan/transactions/controller.go | 79 +++++ api/routes/wormscan/transactions/models.go | 33 +++ common/domain/const.go | 6 + contract-watcher/watcher/aptos_watcher.go | 5 +- contract-watcher/watcher/evm.go | 7 +- contract-watcher/watcher/solana_watcher.go | 5 +- contract-watcher/watcher/terra_watcher.go | 5 +- contract-watcher/watcher/types.go | 7 - contract-watcher/watcher/watcher.go | 11 +- contract-watcher/watcher/watcher_test.go | 43 +-- 18 files changed, 857 insertions(+), 123 deletions(-) create mode 100644 api/routes/wormscan/transactions/models.go delete mode 100644 contract-watcher/watcher/types.go diff --git a/api/docs/docs.go b/api/docs/docs.go index 7cbaabe2..7561d78c 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1,5 +1,4 @@ -// Code generated by swaggo/swag. DO NOT EDIT. - +// Code generated by swaggo/swag. DO NOT EDIT package docs import "github.com/swaggo/swag" @@ -575,12 +574,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } } - } + ] } }, "400": { @@ -893,12 +899,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "ready": { - "type": "string" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "ready": { + "type": "string" + } + } } - } + ] } }, "400": { @@ -1027,6 +1040,43 @@ const docTemplate = `{ } } }, + "/api/v1/transactions/": { + "get": { + "description": "Returns transactions. Output is paginated.", + "tags": [ + "Wormscan" + ], + "operationId": "list-transactions", + "parameters": [ + { + "type": "integer", + "description": "Page number. Starts at 0.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Number of elements per page.", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/transactions.ListTransactionsResponse" + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/vaas/": { "get": { "description": "Returns all VAAs. Output is paginated and can also be be sorted.", @@ -1575,15 +1625,22 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "vaaBytes": { - "type": "array", - "items": { - "type": "integer" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "vaaBytes": { + "type": "array", + "items": { + "type": "integer" + } + } } } - } + ] } }, "400": { @@ -1629,15 +1686,22 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "vaaBytes": { - "type": "array", - "items": { - "type": "integer" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "vaaBytes": { + "type": "array", + "items": { + "type": "integer" + } + } } } - } + ] } }, "400": { @@ -1676,6 +1740,41 @@ const docTemplate = `{ } } }, + "github_com_wormhole-foundation_wormhole-explorer_api_routes_wormscan_transactions.TransactionOverview": { + "type": "object", + "properties": { + "destinationAddress": { + "type": "string" + }, + "destinationChain": { + "$ref": "#/definitions/vaa.ChainID" + }, + "id": { + "type": "string" + }, + "originChain": { + "$ref": "#/definitions/vaa.ChainID" + }, + "status": { + "$ref": "#/definitions/transactions.TxStatus" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + }, + "txHash": { + "type": "string" + }, + "usdAmount": { + "type": "string" + } + } + }, "governor.AvailableNotionalItemResponse": { "type": "object", "properties": { @@ -2427,6 +2526,17 @@ const docTemplate = `{ } } }, + "transactions.ListTransactionsResponse": { + "type": "object", + "properties": { + "transactions": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_wormhole-foundation_wormhole-explorer_api_routes_wormscan_transactions.TransactionOverview" + } + } + } + }, "transactions.ScorecardsResponse": { "type": "object", "properties": { @@ -2522,6 +2632,17 @@ const docTemplate = `{ } } }, + "transactions.TxStatus": { + "type": "string", + "enum": [ + "ongoing", + "completed" + ], + "x-enum-varnames": [ + "TxStatusOngoing", + "TxStatusCompleted" + ] + }, "vaa.ChainID": { "type": "integer", "enum": [ @@ -2667,8 +2788,6 @@ var SwaggerInfo = &swag.Spec{ Description: "Wormhole Guardian API\nThis is the API for the Wormhole Guardian and Explorer.\nThe API has two namespaces: wormscan and guardian.\nwormscan is the namespace for the explorer and the new endpoints. The prefix is /api/v1.\nguardian is the legacy namespace backguard compatible with guardian node API. The prefix is /v1.\nThis API is public and does not require authentication although some endpoints are rate limited.\nCheck each endpoint documentation for more information.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", } func init() { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 3e8cc3d3..322c29eb 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -567,12 +567,19 @@ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } } - } + ] } }, "400": { @@ -885,12 +892,19 @@ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "ready": { - "type": "string" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "ready": { + "type": "string" + } + } } - } + ] } }, "400": { @@ -1019,6 +1033,43 @@ } } }, + "/api/v1/transactions/": { + "get": { + "description": "Returns transactions. Output is paginated.", + "tags": [ + "Wormscan" + ], + "operationId": "list-transactions", + "parameters": [ + { + "type": "integer", + "description": "Page number. Starts at 0.", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Number of elements per page.", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/transactions.ListTransactionsResponse" + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/vaas/": { "get": { "description": "Returns all VAAs. Output is paginated and can also be be sorted.", @@ -1567,15 +1618,22 @@ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "vaaBytes": { - "type": "array", - "items": { - "type": "integer" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "vaaBytes": { + "type": "array", + "items": { + "type": "integer" + } + } } } - } + ] } }, "400": { @@ -1621,15 +1679,22 @@ "200": { "description": "OK", "schema": { - "type": "object", - "properties": { - "vaaBytes": { - "type": "array", - "items": { - "type": "integer" + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "properties": { + "vaaBytes": { + "type": "array", + "items": { + "type": "integer" + } + } } } - } + ] } }, "400": { @@ -1668,6 +1733,41 @@ } } }, + "github_com_wormhole-foundation_wormhole-explorer_api_routes_wormscan_transactions.TransactionOverview": { + "type": "object", + "properties": { + "destinationAddress": { + "type": "string" + }, + "destinationChain": { + "$ref": "#/definitions/vaa.ChainID" + }, + "id": { + "type": "string" + }, + "originChain": { + "$ref": "#/definitions/vaa.ChainID" + }, + "status": { + "$ref": "#/definitions/transactions.TxStatus" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "tokenAmount": { + "type": "string" + }, + "txHash": { + "type": "string" + }, + "usdAmount": { + "type": "string" + } + } + }, "governor.AvailableNotionalItemResponse": { "type": "object", "properties": { @@ -2419,6 +2519,17 @@ } } }, + "transactions.ListTransactionsResponse": { + "type": "object", + "properties": { + "transactions": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_wormhole-foundation_wormhole-explorer_api_routes_wormscan_transactions.TransactionOverview" + } + } + } + }, "transactions.ScorecardsResponse": { "type": "object", "properties": { @@ -2514,6 +2625,17 @@ } } }, + "transactions.TxStatus": { + "type": "string", + "enum": [ + "ongoing", + "completed" + ], + "x-enum-varnames": [ + "TxStatusOngoing", + "TxStatusCompleted" + ] + }, "vaa.ChainID": { "type": "integer", "enum": [ diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 7e0226ef..4210aa4d 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -16,6 +16,29 @@ definitions: index: type: integer type: object + github_com_wormhole-foundation_wormhole-explorer_api_routes_wormscan_transactions.TransactionOverview: + properties: + destinationAddress: + type: string + destinationChain: + $ref: '#/definitions/vaa.ChainID' + id: + type: string + originChain: + $ref: '#/definitions/vaa.ChainID' + status: + $ref: '#/definitions/transactions.TxStatus' + symbol: + type: string + timestamp: + type: string + tokenAmount: + type: string + txHash: + type: string + usdAmount: + type: string + type: object governor.AvailableNotionalItemResponse: properties: bigTransactionSize: @@ -502,6 +525,13 @@ definitions: volume: type: number type: object + transactions.ListTransactionsResponse: + properties: + transactions: + items: + $ref: '#/definitions/github_com_wormhole-foundation_wormhole-explorer_api_routes_wormscan_transactions.TransactionOverview' + type: array + type: object transactions.ScorecardsResponse: properties: 24h_messages: @@ -568,6 +598,14 @@ definitions: volume: type: number type: object + transactions.TxStatus: + enum: + - ongoing + - completed + type: string + x-enum-varnames: + - TxStatusOngoing + - TxStatusCompleted vaa.ChainID: enum: - 0 @@ -1068,10 +1106,12 @@ paths: "200": description: OK schema: - properties: - status: - type: string - type: object + allOf: + - type: object + - properties: + status: + type: string + type: object "400": description: Bad Request "500": @@ -1283,10 +1323,12 @@ paths: "200": description: OK schema: - properties: - ready: - type: string - type: object + allOf: + - type: object + - properties: + ready: + type: string + type: object "400": description: Bad Request "500": @@ -1381,6 +1423,30 @@ paths: description: Internal Server Error tags: - Wormscan + /api/v1/transactions/: + get: + description: Returns transactions. Output is paginated. + operationId: list-transactions + parameters: + - description: Page number. Starts at 0. + in: query + name: page + type: integer + - description: Number of elements per page. + in: query + name: pageSize + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/transactions.ListTransactionsResponse' + "400": + description: Bad Request + "500": + description: Internal Server Error + tags: + - Wormscan /api/v1/vaas/: get: description: Returns all VAAs. Output is paginated and can also be be sorted. @@ -1758,12 +1824,14 @@ paths: "200": description: OK schema: - properties: - vaaBytes: - items: - type: integer - type: array - type: object + allOf: + - type: object + - properties: + vaaBytes: + items: + type: integer + type: array + type: object "400": description: Bad Request "500": @@ -1794,12 +1862,14 @@ paths: "200": description: OK schema: - properties: - vaaBytes: - items: - type: integer - type: array - type: object + allOf: + - type: object + - properties: + vaaBytes: + items: + type: integer + type: array + type: object "400": description: Bad Request "500": diff --git a/api/handlers/transactions/repository.go b/api/handlers/transactions/repository.go index d971e557..d8bfbfc1 100644 --- a/api/handlers/transactions/repository.go +++ b/api/handlers/transactions/repository.go @@ -13,7 +13,9 @@ import ( "github.com/mitchellh/mapstructure" "github.com/pkg/errors" errs "github.com/wormhole-foundation/wormhole-explorer/api/internal/errors" + "github.com/wormhole-foundation/wormhole-explorer/api/internal/pagination" "github.com/wormhole-foundation/wormhole-explorer/api/internal/tvl" + "github.com/wormhole-foundation/wormhole-explorer/api/types" "github.com/wormhole-foundation/wormhole-explorer/common/domain" sdk "github.com/wormhole-foundation/wormhole/sdk/vaa" "go.mongodb.org/mongo-driver/bson" @@ -138,6 +140,12 @@ union(tables: [summarized, raw]) |> top(columns: ["_value"], n: 7) ` +type repositoryCollections struct { + vaas *mongo.Collection + parsedVaa *mongo.Collection + globalTransactions *mongo.Collection +} + type Repository struct { tvl *tvl.Tvl influxCli influxdb2.Client @@ -146,10 +154,8 @@ type Repository struct { bucket30DaysRetention string bucket24HoursRetention string db *mongo.Database - collections struct { - globalTransactions *mongo.Collection - } - logger *zap.Logger + collections repositoryCollections + logger *zap.Logger } func NewRepository( @@ -169,8 +175,12 @@ func NewRepository( bucket30DaysRetention: bucket30DaysRetention, bucketInfiniteRetention: bucketInfiniteRetention, db: db, - collections: struct{ globalTransactions *mongo.Collection }{globalTransactions: db.Collection("globalTransactions")}, - logger: logger, + collections: repositoryCollections{ + vaas: db.Collection("vaas"), + parsedVaa: db.Collection("parsedVaa"), + globalTransactions: db.Collection("globalTransactions"), + }, + logger: logger, } return &r @@ -684,3 +694,254 @@ func (r *Repository) findGlobalTransactionByID(ctx context.Context, q *GlobalTra return &globalTranstaction, nil } + +// TransactionOverview models a brief overview of a transactions (ID, txHash, status, etc.) +type TransactionOverview struct { + ID string `bson:"_id"` + EmitterChain sdk.ChainID `bson:"emitterChain"` + TxHash string `bson:"txHash"` + Timestamp time.Time `bson:"timestamp"` + ToAddress string `bson:"toAddress"` + ToChain sdk.ChainID `bson:"toChain"` + Symbol string `bson:"symbol"` + UsdAmount string `bson:"usdAmount"` + TokenAmount string `bson:"tokenAmount"` + GlobalTransations []GlobalTransactionDoc `bson:"globalTransactions"` +} + +// ListTransactionsInput is used as the output for the function `ListTransactions` +type ListTransactonsOutput struct { + Transactions []TransactionOverview +} + +// ListTransactions returns a sorted list of transactions. +// +// Pagination is implemented using a keyset cursor pattern, based on the (timestamp, ID) pair. +func (r *Repository) ListTransactions( + ctx context.Context, + pagination *pagination.Pagination, +) (*ListTransactonsOutput, error) { + + // Build the aggregation pipeline + var pipeline mongo.Pipeline + { + // Specify sorting criteria + pipeline = append(pipeline, bson.D{ + {"$sort", bson.D{ + bson.E{"timestamp", -1}, + bson.E{"_id", -1}, + }}, + }) + + // left outer join on the `transferPrices` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "transferPrices"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "transferPrices"}, + }}, + }) + + // left outer join on the `vaaIdTxHash` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "vaaIdTxHash"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "vaaIdTxHash"}, + }}, + }) + + // left outer join on the `parsedVaa` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "parsedVaa"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "parsedVaa"}, + }}, + }) + + // left outer join on the `globalTransactions` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "globalTransactions"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "globalTransactions"}, + }}, + }) + + // add nested fields + pipeline = append(pipeline, bson.D{ + {"$addFields", bson.D{ + {"txHash", bson.M{"$arrayElemAt": []interface{}{"$vaaIdTxHash.txHash", 0}}}, + {"toAddress", bson.M{"$arrayElemAt": []interface{}{"$parsedVaa.result.toAddress", 0}}}, + {"toChain", bson.M{"$arrayElemAt": []interface{}{"$parsedVaa.result.toChain", 0}}}, + {"symbol", bson.M{"$arrayElemAt": []interface{}{"$transferPrices.symbol", 0}}}, + {"usdAmount", bson.M{"$arrayElemAt": []interface{}{"$transferPrices.usdAmount", 0}}}, + {"tokenAmount", bson.M{"$arrayElemAt": []interface{}{"$transferPrices.tokenAmount", 0}}}, + }}, + }) + + // Unset unused fields + pipeline = append(pipeline, bson.D{ + {"$unset", []interface{}{"transferPrices", "vaaTxIdHash", "parsedVaa"}}, + }) + + // Skip initial results + pipeline = append(pipeline, bson.D{ + {"$skip", pagination.Skip}, + }) + + // Limit size of results + pipeline = append(pipeline, bson.D{ + {"$limit", pagination.Limit}, + }) + } + + // Execute the aggregation pipeline + cur, err := r.collections.vaas.Aggregate(ctx, pipeline) + if err != nil { + r.logger.Error("failed execute aggregation pipeline", zap.Error(err)) + return nil, err + } + + // Read results from cursor + var documents []TransactionOverview + err = cur.All(ctx, &documents) + if err != nil { + r.logger.Error("failed to decode cursor", zap.Error(err)) + return nil, err + } + + // Build result and return + response := ListTransactonsOutput{ + Transactions: documents, + } + return &response, nil +} + +// ListTransactionsByAddress returns a sorted list of transactions for a given address. +// +// Pagination is implemented using a keyset cursor pattern, based on the (timestamp, ID) pair. +func (r *Repository) ListTransactionsByAddress( + ctx context.Context, + address *types.Address, + pagination *pagination.Pagination, +) (*ListTransactonsOutput, error) { + + // Build the aggregation pipeline + var pipeline mongo.Pipeline + { + // filter by address + pipeline = append(pipeline, bson.D{ + {"$match", bson.D{{"result.toAddress", bson.M{"$eq": "0x" + address.Hex()}}}}, + }) + + // specify sorting criteria + pipeline = append(pipeline, bson.D{ + {"$sort", bson.D{bson.E{"indexedAt", -1}}}, + }) + + // left outer join on the `transferPrices` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "transferPrices"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "transferPrices"}, + }}, + }) + + // left outer join on the `vaas` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "vaas"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "vaas"}, + }}, + }) + + // left outer join on the `vaaIdTxHash` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "vaaIdTxHash"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "vaaIdTxHash"}, + }}, + }) + + // left outer join on the `parsedVaa` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "parsedVaa"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "parsedVaa"}, + }}, + }) + + // left outer join on the `globalTransactions` collection + pipeline = append(pipeline, bson.D{ + {"$lookup", bson.D{ + {"from", "globalTransactions"}, + {"localField", "_id"}, + {"foreignField", "_id"}, + {"as", "globalTransactions"}, + }}, + }) + + // add nested fields + pipeline = append(pipeline, bson.D{ + {"$addFields", bson.D{ + {"txHash", bson.M{"$arrayElemAt": []interface{}{"$vaaIdTxHash.txHash", 0}}}, + {"timestamp", bson.M{"$arrayElemAt": []interface{}{"$vaas.timestamp", 0}}}, + {"toAddress", bson.M{"$arrayElemAt": []interface{}{"$parsedVaa.result.toAddress", 0}}}, + {"toChain", bson.M{"$arrayElemAt": []interface{}{"$parsedVaa.result.toChain", 0}}}, + {"symbol", bson.M{"$arrayElemAt": []interface{}{"$transferPrices.symbol", 0}}}, + {"usdAmount", bson.M{"$arrayElemAt": []interface{}{"$transferPrices.usdAmount", 0}}}, + {"tokenAmount", bson.M{"$arrayElemAt": []interface{}{"$transferPrices.tokenAmount", 0}}}, + }}, + }) + + // Unset unused fields + pipeline = append(pipeline, bson.D{ + {"$unset", []interface{}{"transferPrices", "vaas", "vaaTxIdHash", "parsedVaa"}}, + }) + + // Skip initial results + pipeline = append(pipeline, bson.D{ + {"$skip", pagination.Skip}, + }) + + // Limit size of results + pipeline = append(pipeline, bson.D{ + {"$limit", pagination.Limit}, + }) + } + + // Execute the aggregation pipeline + cur, err := r.collections.parsedVaa.Aggregate(ctx, pipeline) + if err != nil { + r.logger.Error("failed execute aggregation pipeline", zap.Error(err)) + return nil, err + } + + // Read results from cursor + var documents []TransactionOverview + err = cur.All(ctx, &documents) + if err != nil { + r.logger.Error("failed to decode cursor", zap.Error(err)) + return nil, err + } + + // Build result and return + response := ListTransactonsOutput{ + Transactions: documents, + } + return &response, nil +} diff --git a/api/handlers/transactions/service.go b/api/handlers/transactions/service.go index 1f1bc5c7..f32fb246 100644 --- a/api/handlers/transactions/service.go +++ b/api/handlers/transactions/service.go @@ -5,6 +5,7 @@ import ( "fmt" errs "github.com/wormhole-foundation/wormhole-explorer/api/internal/errors" + "github.com/wormhole-foundation/wormhole-explorer/api/internal/pagination" "github.com/wormhole-foundation/wormhole-explorer/api/types" "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole/sdk/vaa" @@ -73,3 +74,20 @@ func (s *Service) GetTokenByChainAndAddress(ctx context.Context, chainID vaa.Cha Decimals: tokenMetadata.Decimals, }, nil } + +func (s *Service) ListTransactions( + ctx context.Context, + pagination *pagination.Pagination, +) (*ListTransactonsOutput, error) { + + return s.repo.ListTransactions(ctx, pagination) +} + +func (s *Service) ListTransactionsByAddress( + ctx context.Context, + address *types.Address, + pagination *pagination.Pagination, +) (*ListTransactonsOutput, error) { + + return s.repo.ListTransactionsByAddress(ctx, address, pagination) +} diff --git a/api/middleware/extract_parameters.go b/api/middleware/extract_parameters.go index f55958c3..94f69891 100644 --- a/api/middleware/extract_parameters.go +++ b/api/middleware/extract_parameters.go @@ -175,7 +175,33 @@ func ExtractObservationHash(c *fiber.Ctx, l *zap.Logger) (string, error) { return hash, nil } -func ExtractAddress(c *fiber.Ctx, l *zap.Logger) (*types.Address, error) { +// ExtractAddressFromQueryParams parses the `address` parameter from the query string. +// +// If the parameter doesn't exist, the function returns a nil address without errors. +func ExtractAddressFromQueryParams(c *fiber.Ctx, l *zap.Logger) (*types.Address, error) { + + val := c.Query("address") + + if val == "" { + return nil, nil + } + + // Attempt to parse the address + addr, err := types.StringToAddress(val, true /*acceptSolanaFormat*/) + if err != nil { + requestID := fmt.Sprintf("%v", c.Locals("requestid")) + l.Error("failed to decode address", + zap.Error(err), + zap.String("requestID", requestID), + ) + return nil, response.NewInvalidParamError(c, "MALFORMED ADDR", errors.WithStack(err)) + } + + return addr, nil +} + +// ExtractAddressFromPath parses the `id` parameter from the route path. +func ExtractAddressFromPath(c *fiber.Ctx, l *zap.Logger) (*types.Address, error) { val := c.Params("id") diff --git a/api/routes/wormscan/address/controller.go b/api/routes/wormscan/address/controller.go index d6eb1ec9..72932f0e 100644 --- a/api/routes/wormscan/address/controller.go +++ b/api/routes/wormscan/address/controller.go @@ -38,7 +38,7 @@ func NewController(srv *address.Service, logger *zap.Logger) *Controller { // @Router /api/v1/address/{address} [get] func (c *Controller) FindById(ctx *fiber.Ctx) error { - address, err := middleware.ExtractAddress(ctx, c.logger) + address, err := middleware.ExtractAddressFromPath(ctx, c.logger) if err != nil { return err } diff --git a/api/routes/wormscan/routes.go b/api/routes/wormscan/routes.go index cdec2436..f42a923a 100644 --- a/api/routes/wormscan/routes.go +++ b/api/routes/wormscan/routes.go @@ -62,7 +62,7 @@ func RegisterRoutes( // accounts resource api.Get("/address/:id", addressCtrl.FindById) - // analytics + // analytics, transactions, custom endpoints api.Get("/global-tx/:chain/:emitter/:sequence", transactionCtrl.FindGlobalTransactionByID) api.Get("/last-txs", transactionCtrl.GetLastTransactions) api.Get("/scorecards", transactionCtrl.GetScorecards) @@ -70,6 +70,7 @@ func RegisterRoutes( 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) + api.Get("/transactions", transactionCtrl.ListTransactions) // vaas resource vaas := api.Group("/vaas") diff --git a/api/routes/wormscan/transactions/controller.go b/api/routes/wormscan/transactions/controller.go index 5f0ee3a3..258aa550 100644 --- a/api/routes/wormscan/transactions/controller.go +++ b/api/routes/wormscan/transactions/controller.go @@ -8,6 +8,7 @@ import ( "github.com/wormhole-foundation/wormhole-explorer/api/handlers/transactions" "github.com/wormhole-foundation/wormhole-explorer/api/middleware" "github.com/wormhole-foundation/wormhole-explorer/common/domain" + sdk "github.com/wormhole-foundation/wormhole/sdk/vaa" "go.uber.org/zap" ) @@ -347,3 +348,81 @@ func (c *Controller) GetTokenByChainAndAddress(ctx *fiber.Ctx) error { return ctx.JSON(token) } + +// ListTransactions godoc +// @Description Returns transactions. Output is paginated. +// @Tags Wormscan +// @ID list-transactions +// @Param page query integer false "Page number. Starts at 0." +// @Param pageSize query integer false "Number of elements per page." +// @Success 200 {object} ListTransactionsResponse +// @Failure 400 +// @Failure 500 +// @Router /api/v1/transactions/ [get] +func (c *Controller) ListTransactions(ctx *fiber.Ctx) error { + + // Extract query parameters + pagination, err := middleware.ExtractPagination(ctx) + if err != nil { + return err + } + address, err := middleware.ExtractAddressFromQueryParams(ctx, c.logger) + if err != nil { + return err + } + + // Query transactions from the database + var queryResult *transactions.ListTransactonsOutput + if address != nil { + queryResult, err = c.srv.ListTransactionsByAddress(ctx.Context(), address, pagination) + } else { + queryResult, err = c.srv.ListTransactions(ctx.Context(), pagination) + } + if err != nil { + return err + } + + // Convert query results into the response model + response := ListTransactionsResponse{ + Transactions: make([]TransactionOverview, 0, len(queryResult.Transactions)), + } + for i := range queryResult.Transactions { + + tx := TransactionOverview{ + ID: queryResult.Transactions[i].ID, + OriginChain: queryResult.Transactions[i].EmitterChain, + Timestamp: queryResult.Transactions[i].Timestamp, + DestinationAddress: queryResult.Transactions[i].ToAddress, + DestinationChain: queryResult.Transactions[i].ToChain, + Symbol: queryResult.Transactions[i].Symbol, + TokenAmount: queryResult.Transactions[i].TokenAmount, + UsdAmount: queryResult.Transactions[i].UsdAmount, + } + + // For Solana VAAs, the txHash that we get from the gossip network is not the real transacion hash, + // so we have to overwrite it with the real txHash. + if queryResult.Transactions[i].EmitterChain == sdk.ChainIDSolana && + len(queryResult.Transactions[i].GlobalTransations) == 1 && + queryResult.Transactions[i].GlobalTransations[0].OriginTx != nil { + + tx.TxHash = queryResult.Transactions[i].GlobalTransations[0].OriginTx.TxHash + + } else { + tx.TxHash = queryResult.Transactions[i].TxHash + } + + // Set the status based on the outcome of the redeem transaction. + if len(queryResult.Transactions[i].GlobalTransations) == 1 && + queryResult.Transactions[i].GlobalTransations[0].DestinationTx != nil && + queryResult.Transactions[i].GlobalTransations[0].DestinationTx.Status == domain.DstTxStatusConfirmed { + + tx.Status = TxStatusCompleted + } else { + tx.Status = TxStatusOngoing + } + + response.Transactions = append(response.Transactions, tx) + } + + return ctx.JSON(response) +} diff --git a/api/routes/wormscan/transactions/models.go b/api/routes/wormscan/transactions/models.go new file mode 100644 index 00000000..c45912c8 --- /dev/null +++ b/api/routes/wormscan/transactions/models.go @@ -0,0 +1,33 @@ +package transactions + +import ( + "time" + + sdk "github.com/wormhole-foundation/wormhole/sdk/vaa" +) + +type TxStatus string + +const ( + TxStatusOngoing TxStatus = "ongoing" + TxStatusCompleted TxStatus = "completed" +) + +// TransactionOverview is a brief description of a transaction (e.g. ID, txHash, status, etc.). +type TransactionOverview struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + TxHash string `json:"txHash,omitempty"` + OriginChain sdk.ChainID `json:"originChain"` + DestinationAddress string `json:"destinationAddress,omitempty"` + DestinationChain sdk.ChainID `json:"destinationChain,omitempty"` + TokenAmount string `json:"tokenAmount,omitempty"` + UsdAmount string `json:"usdAmount,omitempty"` + Symbol string `json:"symbol,omitempty"` + Status TxStatus `json:"status"` +} + +// ListTransactionsResponse is the "200 OK" response model for `GET /api/v1/transactions`. +type ListTransactionsResponse struct { + Transactions []TransactionOverview `json:"transactions"` +} diff --git a/common/domain/const.go b/common/domain/const.go index 2056e546..123ee115 100644 --- a/common/domain/const.go +++ b/common/domain/const.go @@ -24,3 +24,9 @@ const ( // SourceTxStatusConfirmed indicates that the transaciton has been processed successfully. SourceTxStatusConfirmed SourceTxStatus = "confirmed" ) + +const ( + DstTxStatusFailedToProcess = "failed" + DstTxStatusConfirmed = "completed" + DstTxStatusUnkonwn = "unknown" +) diff --git a/contract-watcher/watcher/aptos_watcher.go b/contract-watcher/watcher/aptos_watcher.go index 64b28d7e..5653375f 100644 --- a/contract-watcher/watcher/aptos_watcher.go +++ b/contract-watcher/watcher/aptos_watcher.go @@ -10,6 +10,7 @@ import ( "time" "github.com/avast/retry-go" + "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/internal/aptos" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/storage" "github.com/wormhole-foundation/wormhole/sdk/vaa" @@ -223,9 +224,9 @@ func (w *AptosWatcher) processTransaction(ctx context.Context, tx aptos.Transact zap.Error(err)) return } - status := TxStatusFailedToProcess + status := domain.DstTxStatusFailedToProcess if txResult.Success { - status = TxStatusConfirmed + status = domain.DstTxStatusConfirmed } updatedAt := time.Now() globalTx := storage.TransactionUpdate{ diff --git a/contract-watcher/watcher/evm.go b/contract-watcher/watcher/evm.go index 4f7ecc78..1673276e 100644 --- a/contract-watcher/watcher/evm.go +++ b/contract-watcher/watcher/evm.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/config" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/storage" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/support" @@ -53,11 +54,11 @@ type EvmTransaction struct { func getTxStatus(status string) string { switch status { case TxStatusSuccess: - return TxStatusConfirmed + return domain.DstTxStatusConfirmed case TxStatusFailReverted: - return TxStatusFailedToProcess + return domain.DstTxStatusFailedToProcess default: - return TxStatusUnkonwn + return domain.DstTxStatusUnkonwn } } diff --git a/contract-watcher/watcher/solana_watcher.go b/contract-watcher/watcher/solana_watcher.go index 79ad702c..5b8ee707 100644 --- a/contract-watcher/watcher/solana_watcher.go +++ b/contract-watcher/watcher/solana_watcher.go @@ -12,6 +12,7 @@ import ( solana_types "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" "github.com/near/borsh-go" + "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/internal/solana" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/storage" "github.com/wormhole-foundation/wormhole/sdk/vaa" @@ -378,7 +379,7 @@ func (w *SolanaWatcher) getAccountAddress(inst solana_types.CompiledInstruction, func (w *SolanaWatcher) getStatus(txRpc *rpc.TransactionWithMeta) string { if txRpc.Meta != nil && txRpc.Meta.Err != nil { - return TxStatusFailedToProcess + return domain.DstTxStatusFailedToProcess } - return TxStatusConfirmed + return domain.DstTxStatusConfirmed } diff --git a/contract-watcher/watcher/terra_watcher.go b/contract-watcher/watcher/terra_watcher.go index ec059b39..239bec09 100644 --- a/contract-watcher/watcher/terra_watcher.go +++ b/contract-watcher/watcher/terra_watcher.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/internal/terra" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/storage" "github.com/wormhole-foundation/wormhole/sdk/vaa" @@ -287,9 +288,9 @@ func filterTransactionMethod(method string) bool { func getStatus(tx terra.Tx) string { if tx.Code == 0 { - return TxStatusConfirmed + return domain.DstTxStatusConfirmed } - return TxStatusFailedToProcess + return domain.DstTxStatusFailedToProcess } func (w *TerraWatcher) Close() { diff --git a/contract-watcher/watcher/types.go b/contract-watcher/watcher/types.go deleted file mode 100644 index 22c477d0..00000000 --- a/contract-watcher/watcher/types.go +++ /dev/null @@ -1,7 +0,0 @@ -package watcher - -const ( - TxStatusFailedToProcess = "failed" - TxStatusConfirmed = "completed" - TxStatusUnkonwn = "unknown" -) diff --git a/contract-watcher/watcher/watcher.go b/contract-watcher/watcher/watcher.go index 8b73e440..ec56ed86 100644 --- a/contract-watcher/watcher/watcher.go +++ b/contract-watcher/watcher/watcher.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/storage" "go.uber.org/zap" ) @@ -38,27 +39,27 @@ func updateGlobalTransaction(ctx context.Context, tx storage.TransactionUpdate, // checkTxShouldBeUpdated checks if the transaction should be updated. func checkTxShouldBeUpdated(ctx context.Context, tx storage.TransactionUpdate, getGlobalTransactionByIDFunc FuncGetGlobalTransactionById) (bool, error) { switch tx.Destination.Status { - case TxStatusConfirmed: + case domain.DstTxStatusConfirmed: return true, nil - case TxStatusFailedToProcess: + case domain.DstTxStatusFailedToProcess: // check if the transaction exists from the same vaa ID. oldTx, err := getGlobalTransactionByIDFunc(ctx, tx.ID) if err != nil { return true, nil } // if the transaction was already confirmed, then no update it. - if oldTx.Destination.Status == TxStatusConfirmed { + if oldTx.Destination.Status == domain.DstTxStatusConfirmed { return false, ErrTxfailedCannotBeUpdated } return true, nil - case TxStatusUnkonwn: + case domain.DstTxStatusUnkonwn: // check if the transaction exists from the same vaa ID. oldTx, err := getGlobalTransactionByIDFunc(ctx, tx.ID) if err != nil { return true, nil } // if the transaction was already confirmed or failed to process, then no update it. - if oldTx.Destination.Status == TxStatusConfirmed || oldTx.Destination.Status == TxStatusFailedToProcess { + if oldTx.Destination.Status == domain.DstTxStatusConfirmed || oldTx.Destination.Status == domain.DstTxStatusFailedToProcess { return false, ErrTxUnknowCannotBeUpdated } return true, nil diff --git a/contract-watcher/watcher/watcher_test.go b/contract-watcher/watcher/watcher_test.go index 02c93931..3409387f 100644 --- a/contract-watcher/watcher/watcher_test.go +++ b/contract-watcher/watcher/watcher_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/wormhole-foundation/wormhole-explorer/common/domain" "github.com/wormhole-foundation/wormhole-explorer/contract-watcher/storage" ) @@ -21,7 +22,7 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status completed and does not exist transaction with the same vaa ID", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{}, storage.ErrDocNotFound @@ -33,12 +34,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status completed and already exists a transaction with the same vaa ID with status completed", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, nil }, expectedUpdate: true, @@ -48,12 +49,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status completed and already exist a transaction with the same vaa ID with status failed", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, nil }, expectedUpdate: true, @@ -63,12 +64,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status completed and already exist a transaction with the same vaa ID with status unknown", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, nil }, expectedUpdate: true, @@ -78,7 +79,7 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status failed and does not exist transaction with the same vaa ID", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{}, storage.ErrDocNotFound @@ -90,12 +91,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status failed and already exists a transaction with the same vaa ID with status completed", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, nil }, expectedUpdate: false, @@ -105,12 +106,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status failed and already exist a transaction with the same vaa ID with status failed", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, nil }, expectedUpdate: true, @@ -120,12 +121,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status failed and already exist a transaction with the same vaa ID with status unknown", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, nil }, expectedUpdate: true, @@ -135,7 +136,7 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status unknown and does not exist transaction with the same vaa ID", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{}, storage.ErrDocNotFound @@ -147,12 +148,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status unknown and already exists a transaction with the same vaa ID with status completed", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusConfirmed, + Status: domain.DstTxStatusConfirmed, }}, nil }, expectedUpdate: false, @@ -162,12 +163,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status unknown and already exist a transaction with the same vaa ID with status failed", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusFailedToProcess, + Status: domain.DstTxStatusFailedToProcess, }}, nil }, expectedUpdate: false, @@ -177,12 +178,12 @@ func TestCheckTxShouldBeUpdated(t *testing.T) { name: "tx with status unknown and already exist a transaction with the same vaa ID with status unknown", inputTx: storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, inputGetGlobalTransactionByIDFunc: func(ctx context.Context, id string) (storage.TransactionUpdate, error) { return storage.TransactionUpdate{ Destination: storage.DestinationTx{ - Status: TxStatusUnkonwn, + Status: domain.DstTxStatusUnkonwn, }}, nil }, expectedUpdate: true,