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)
This commit is contained in:
agodnic 2023-06-12 11:43:48 -03:00 committed by GitHub
parent d0436fa46d
commit a0475ab17e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 857 additions and 123 deletions

View File

@ -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() {

View File

@ -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": [

View File

@ -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":

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")

View File

@ -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
}

View File

@ -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")

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -24,3 +24,9 @@ const (
// SourceTxStatusConfirmed indicates that the transaciton has been processed successfully.
SourceTxStatusConfirmed SourceTxStatus = "confirmed"
)
const (
DstTxStatusFailedToProcess = "failed"
DstTxStatusConfirmed = "completed"
DstTxStatusUnkonwn = "unknown"
)

View File

@ -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{

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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() {

View File

@ -1,7 +0,0 @@
package watcher
const (
TxStatusFailedToProcess = "failed"
TxStatusConfirmed = "completed"
TxStatusUnkonwn = "unknown"
)

View File

@ -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

View File

@ -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,