Add route `GET /api/v1/address/{address}` (#228)

### Summary

This pull request adds the new route `GET /api/v1/address/{address}`, which returns the transactions in which the given address participated.

Examples:
* https://api.staging.wormscan.io/api/v1/address/0x0000000000000000000000001ef2e0219841d1a540d99c432a6eddb75deed1b7
* 000000000040d99c432a6eddb75deed1b7
* 000000000040d99c432a6eddb75deed1b7?page=0&pageSize=2
* 000000000040d99c432a6eddb75deed1b7?page=1&pageSize=2
* https://api.staging.wormscan.io/api/v1/address/1111111111114Sd894pYPPeXjZCDN5Gv8KCzwFGN

Tracking issue: https://github.com/wormhole-foundation/wormhole-explorer/issues/222
This commit is contained in:
agodnic 2023-04-12 15:51:33 -03:00 committed by GitHub
parent 24673d4f31
commit 6fcf8f8270
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 517 additions and 31 deletions

View File

@ -1,5 +1,4 @@
// Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
// Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
@ -25,6 +24,53 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/v1/address/{address}": {
"get": {
"description": "Lookup an address",
"tags": [
"Wormscan"
],
"operationId": "find-address-by-id",
"parameters": [
{
"type": "string",
"description": "address",
"name": "address",
"in": "path",
"required": true
},
{
"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/response.Response-address_AddressOverview"
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/global-tx/{chain_id}/{emitter}/{seq}": {
"get": {
"description": "Find a global transaction by ID.",
@ -1515,6 +1561,17 @@ const docTemplate = `{
}
},
"definitions": {
"address.AddressOverview": {
"type": "object",
"properties": {
"vaas": {
"type": "array",
"items": {
"$ref": "#/definitions/vaa.VaaDoc"
}
}
}
},
"github_com_wormhole-foundation_wormhole-explorer_api_routes_guardian_guardian.GuardianSet": {
"type": "object",
"properties": {
@ -2046,6 +2103,17 @@ const docTemplate = `{
}
}
},
"response.Response-address_AddressOverview": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/address.AddressOverview"
},
"pagination": {
"$ref": "#/definitions/response.ResponsePagination"
}
}
},
"response.Response-array_governor_EnqueuedVaaDetail": {
"type": "object",
"properties": {
@ -2356,6 +2424,10 @@ const docTemplate = `{
"indexedAt": {
"type": "string"
},
"nativeTxHash": {
"description": "NativeTxHash is an extension field - it is not present in the guardian API.",
"type": "string"
},
"payload": {
"description": "Payload is an extension field - it is not present in the guardian API.",
"type": "object",

View File

@ -17,6 +17,53 @@
},
"basePath": "/v1",
"paths": {
"/api/v1/address/{address}": {
"get": {
"description": "Lookup an address",
"tags": [
"Wormscan"
],
"operationId": "find-address-by-id",
"parameters": [
{
"type": "string",
"description": "address",
"name": "address",
"in": "path",
"required": true
},
{
"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/response.Response-address_AddressOverview"
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/global-tx/{chain_id}/{emitter}/{seq}": {
"get": {
"description": "Find a global transaction by ID.",
@ -1507,6 +1554,17 @@
}
},
"definitions": {
"address.AddressOverview": {
"type": "object",
"properties": {
"vaas": {
"type": "array",
"items": {
"$ref": "#/definitions/vaa.VaaDoc"
}
}
}
},
"github_com_wormhole-foundation_wormhole-explorer_api_routes_guardian_guardian.GuardianSet": {
"type": "object",
"properties": {
@ -2038,6 +2096,17 @@
}
}
},
"response.Response-address_AddressOverview": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/address.AddressOverview"
},
"pagination": {
"$ref": "#/definitions/response.ResponsePagination"
}
}
},
"response.Response-array_governor_EnqueuedVaaDetail": {
"type": "object",
"properties": {
@ -2348,6 +2417,10 @@
"indexedAt": {
"type": "string"
},
"nativeTxHash": {
"description": "NativeTxHash is an extension field - it is not present in the guardian API.",
"type": "string"
},
"payload": {
"description": "Payload is an extension field - it is not present in the guardian API.",
"type": "object",

View File

@ -1,5 +1,12 @@
basePath: /v1
definitions:
address.AddressOverview:
properties:
vaas:
items:
$ref: '#/definitions/vaa.VaaDoc'
type: array
type: object
github_com_wormhole-foundation_wormhole-explorer_api_routes_guardian_guardian.GuardianSet:
properties:
addresses:
@ -345,6 +352,13 @@ definitions:
version:
type: integer
type: object
response.Response-address_AddressOverview:
properties:
data:
$ref: '#/definitions/address.AddressOverview'
pagination:
$ref: '#/definitions/response.ResponsePagination'
type: object
response.Response-array_governor_EnqueuedVaaDetail:
properties:
data:
@ -566,6 +580,10 @@ definitions:
type: string
indexedAt:
type: string
nativeTxHash:
description: NativeTxHash is an extension field - it is not present in the
guardian API.
type: string
payload:
additionalProperties: true
description: Payload is an extension field - it is not present in the guardian
@ -609,6 +627,37 @@ info:
title: Wormhole Guardian API
version: "1.0"
paths:
/api/v1/address/{address}:
get:
description: Lookup an address
operationId: find-address-by-id
parameters:
- description: address
in: path
name: address
required: true
type: string
- 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/response.Response-address_AddressOverview'
"400":
description: Bad Request
"404":
description: Not Found
"500":
description: Internal Server Error
tags:
- Wormscan
/api/v1/global-tx/{chain_id}/{emitter}/{seq}:
get:
description: Find a global transaction by ID.

View File

@ -85,7 +85,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mr-tron/base58 v1.2.0
github.com/multiformats/go-base32 v0.0.4 // indirect
github.com/multiformats/go-base36 v0.1.0 // indirect
github.com/multiformats/go-multiaddr v0.6.0 // indirect

View File

@ -0,0 +1,7 @@
package address
import "github.com/wormhole-foundation/wormhole-explorer/api/handlers/vaa"
type AddressOverview struct {
Vaas []*vaa.VaaDoc `json:"vaas"`
}

View File

@ -0,0 +1,123 @@
package address
import (
"context"
"fmt"
"github.com/wormhole-foundation/wormhole-explorer/api/handlers/vaa"
"github.com/wormhole-foundation/wormhole-explorer/api/types"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type Repository struct {
db *mongo.Database
logger *zap.Logger
collections struct {
parsedVaa *mongo.Collection
}
}
func NewRepository(db *mongo.Database, logger *zap.Logger) *Repository {
return &Repository{db: db,
logger: logger.With(zap.String("module", "AddressRepository")),
collections: struct {
parsedVaa *mongo.Collection
}{
parsedVaa: db.Collection("parsedVaa"),
},
}
}
type GetAddressOverviewParams struct {
Address *types.Address
Skip int64
Limit int64
}
func (r *Repository) GetAddressOverview(ctx context.Context, params *GetAddressOverviewParams) (*AddressOverview, error) {
// build a query pipeline based on input parameters
var pipeline mongo.Pipeline
{
// filter by address
pipeline = append(pipeline, bson.D{
{"$match", bson.D{
{"$or", bson.A{
bson.D{{"result.fromAddress", bson.D{{"$eq", "0x" + params.Address.Hex()}}}},
bson.D{{"result.toAddress", bson.M{"$eq": "0x" + params.Address.Hex()}}},
}},
}},
})
// specify sorting criteria
pipeline = append(pipeline, bson.D{
{"$sort", bson.D{bson.E{"indexedAt", -1}}},
})
// left outer join on the `vaas` collection
pipeline = append(pipeline, bson.D{
{"$lookup", bson.D{
{"from", "vaas"},
{"localField", "_id"},
{"foreignField", "_id"},
{"as", "vaas"},
}},
})
// skip initial results
if params.Skip != 0 {
pipeline = append(pipeline, bson.D{
{"$skip", params.Skip},
})
}
// limit size of results
pipeline = append(pipeline, bson.D{
{"$limit", params.Limit},
})
}
// execute the aggregation pipeline
cur, err := r.collections.parsedVaa.Aggregate(ctx, pipeline)
if err != nil {
requestID := fmt.Sprintf("%v", ctx.Value("requestid"))
r.logger.Error("failed execute Aggregate command to get vaa with payload",
zap.Error(err),
zap.Any("params", params),
zap.String("requestID", requestID),
)
return nil, err
}
// read results from cursor
var documents []struct {
ID string `bson:"_id"`
Vaas []vaa.VaaDoc `bson:"vaas"`
}
err = cur.All(ctx, &documents)
if err != nil {
requestID := fmt.Sprintf("%v", ctx.Value("requestid"))
r.logger.Error("failed to decode cursor for account activity",
zap.Error(err),
zap.Any("params", params),
zap.String("requestID", requestID),
)
return nil, err
}
// build the result and return
var vaas []*vaa.VaaDoc
for i := range documents {
if len(documents[i].Vaas) != 1 {
r.logger.Warn("expected exactly 1 vaa document",
zap.Int("numVaas", len(documents[i].Vaas)),
zap.String("_id", documents[i].ID),
)
}
vaas = append(vaas, &documents[i].Vaas[0])
}
return &AddressOverview{Vaas: vaas}, nil
}

View File

@ -0,0 +1,47 @@
package address
import (
"context"
"github.com/wormhole-foundation/wormhole-explorer/api/internal/pagination"
"github.com/wormhole-foundation/wormhole-explorer/api/response"
"github.com/wormhole-foundation/wormhole-explorer/api/types"
"go.uber.org/zap"
)
type Service struct {
repo *Repository
logger *zap.Logger
}
func NewService(r *Repository, logger *zap.Logger) *Service {
srv := Service{
repo: r,
logger: logger.With(zap.String("module", "AddressService")),
}
return &srv
}
func (s *Service) GetAddressOverview(
ctx context.Context,
address *types.Address,
pagination *pagination.Pagination,
) (*response.Response[*AddressOverview], error) {
response := &response.Response[*AddressOverview]{}
p := GetAddressOverviewParams{
Address: address,
Skip: pagination.Skip,
Limit: pagination.Limit,
}
overview, err := s.repo.GetAddressOverview(ctx, &p)
if err != nil {
return response, err
}
response.Data = overview
return response, nil
}

View File

@ -18,6 +18,7 @@ import (
"github.com/improbable-eng/grpc-web/go/grpcweb"
influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/wormhole-foundation/wormhole-explorer/api/handlers/address"
"github.com/wormhole-foundation/wormhole-explorer/api/handlers/governor"
"github.com/wormhole-foundation/wormhole-explorer/api/handlers/heartbeats"
"github.com/wormhole-foundation/wormhole-explorer/api/handlers/infrastructure"
@ -102,6 +103,7 @@ func main() {
influxCli := newInfluxClient(cfg.Influx.URL, cfg.Influx.Token)
// Set up repositories
addressRepo := address.NewRepository(db, rootLogger)
vaaRepo := vaa.NewRepository(db, rootLogger)
obsRepo := observations.NewRepository(db, rootLogger)
governorRepo := governor.NewRepository(db, rootLogger)
@ -110,6 +112,7 @@ func main() {
transactionsRepo := transactions.NewRepository(influxCli, cfg.Influx.Organization, cfg.Influx.Bucket, db, rootLogger)
// Set up services
addressService := address.NewService(addressRepo, rootLogger)
vaaService := vaa.NewService(vaaRepo, cacheGetFunc, rootLogger)
obsService := observations.NewService(obsRepo, rootLogger)
governorService := governor.NewService(governorRepo, rootLogger)
@ -136,7 +139,7 @@ func main() {
// Set up route handlers
app.Get("/swagger.json", GetSwagger)
wormscan.RegisterRoutes(app, rootLogger, vaaService, obsService, governorService, infrastructureService, transactionsService)
wormscan.RegisterRoutes(app, rootLogger, addressService, vaaService, obsService, governorService, infrastructureService, transactionsService)
guardian.RegisterRoutes(cfg, app, rootLogger, vaaService, governorService, heartbeatsService)
// Set up gRPC handlers

View File

@ -8,7 +8,6 @@ import (
"strings"
"time"
solana "github.com/gagliardetto/solana-go"
"github.com/gofiber/fiber/v2"
"github.com/pkg/errors"
"github.com/wormhole-foundation/wormhole-explorer/api/response"
@ -45,27 +44,14 @@ func ExtractEmitterAddr(c *fiber.Ctx, l *zap.Logger, chainIdHint *sdk.ChainID) (
emitterStr := c.Params("emitter")
// If the chain ID is Solana, attempt to parse the emitter as a Solana address.
// Decide whether to accept the Solana address format based on the context
var acceptSolanaFormat bool
if chainIdHint != nil && *chainIdHint == sdk.ChainIDSolana {
// If the address fails to parse, just fall back to the Wormhole format.
sig, err := solana.PublicKeyFromBase58(emitterStr)
if err == nil {
// This step is not expected to fail, since Solana and Wormhole addresses have the same size.
// However, if it does, we log the error.
emitter, err := types.BytesToAddress(sig[:])
if err == nil {
return emitter, nil
}
l.Warn("failed to convert Solana address to Wormhole address",
zap.String("emitterAddress", emitterStr),
zap.Error(err),
)
}
acceptSolanaFormat = true
}
// Attempt to parse the address according to the Wormhole hex format.
emitter, err := types.StringToAddress(emitterStr)
// Attempt to parse the address
emitter, err := types.StringToAddress(emitterStr, acceptSolanaFormat)
if err != nil {
requestID := fmt.Sprintf("%v", c.Locals("requestid"))
l.Error("failed to convert emitter to wormhole address",
@ -107,8 +93,8 @@ func ExtractGuardianAddress(c *fiber.Ctx, l *zap.Logger) (*types.Address, error)
return nil, response.NewInvalidParamError(c, "MALFORMED GUARDIAN ADDR", nil)
}
// validate the address
guardianAddress, err := types.StringToAddress(tmp)
// Attempt to parse the address
guardianAddress, err := types.StringToAddress(tmp, false /*acceptSolanaFormat*/)
if err != nil {
requestID := fmt.Sprintf("%v", c.Locals("requestid"))
l.Error("failed to decode guardian address",
@ -188,6 +174,24 @@ func ExtractObservationHash(c *fiber.Ctx, l *zap.Logger) (string, error) {
return hash, nil
}
func ExtractAddress(c *fiber.Ctx, l *zap.Logger) (*types.Address, error) {
val := c.Params("id")
// 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
}
// GetTxHash parses the `txHash` parameter from query params.
func GetTxHash(c *fiber.Ctx, l *zap.Logger) (*types.TxHash, error) {

View File

@ -0,0 +1,60 @@
package address
import (
"github.com/gofiber/fiber/v2"
"github.com/wormhole-foundation/wormhole-explorer/api/handlers/address"
"github.com/wormhole-foundation/wormhole-explorer/api/internal/errors"
"github.com/wormhole-foundation/wormhole-explorer/api/middleware" // required by swaggo
_ "github.com/wormhole-foundation/wormhole-explorer/api/response" // required by swaggo
"go.uber.org/zap"
)
type Controller struct {
srv *address.Service
logger *zap.Logger
}
func NewController(srv *address.Service, logger *zap.Logger) *Controller {
c := Controller{
srv: srv,
logger: logger.With(zap.String("module", "AddressController")),
}
return &c
}
// FindById godoc
// @Description Lookup an address
// @Tags Wormscan
// @ID find-address-by-id
// @Param address path string true "address"
// @Param page query integer false "Page number. Starts at 0."
// @Param pageSize query integer false "Number of elements per page."
// @Success 200 {object} response.Response[address.AddressOverview]
// @Failure 400
// @Failure 404
// @Failure 500
// @Router /api/v1/address/{address} [get]
func (c *Controller) FindById(ctx *fiber.Ctx) error {
address, err := middleware.ExtractAddress(ctx, c.logger)
if err != nil {
return err
}
pagination, err := middleware.ExtractPagination(ctx)
if err != nil {
return err
}
response, err := c.srv.GetAddressOverview(ctx.Context(), address, pagination)
if err != nil {
return err
}
if len(response.Data.Vaas) == 0 {
return errors.ErrNotFound
}
return ctx.JSON(response)
}

View File

@ -6,11 +6,13 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
addrsvc "github.com/wormhole-foundation/wormhole-explorer/api/handlers/address"
govsvc "github.com/wormhole-foundation/wormhole-explorer/api/handlers/governor"
infrasvc "github.com/wormhole-foundation/wormhole-explorer/api/handlers/infrastructure"
obssvc "github.com/wormhole-foundation/wormhole-explorer/api/handlers/observations"
trxsvc "github.com/wormhole-foundation/wormhole-explorer/api/handlers/transactions"
vaasvc "github.com/wormhole-foundation/wormhole-explorer/api/handlers/vaa"
"github.com/wormhole-foundation/wormhole-explorer/api/routes/wormscan/address"
"github.com/wormhole-foundation/wormhole-explorer/api/routes/wormscan/governor"
"github.com/wormhole-foundation/wormhole-explorer/api/routes/wormscan/infrastructure"
"github.com/wormhole-foundation/wormhole-explorer/api/routes/wormscan/observations"
@ -32,6 +34,7 @@ var cacheConfig = cache.Config{
func RegisterRoutes(
app *fiber.App,
rootLogger *zap.Logger,
addressService *addrsvc.Service,
vaaService *vaasvc.Service,
obsService *obssvc.Service,
governorService *govsvc.Service,
@ -40,6 +43,7 @@ func RegisterRoutes(
) {
// Set up controllers
addressCtrl := address.NewController(addressService, rootLogger)
vaaCtrl := vaa.NewController(vaaService, rootLogger)
observationsCtrl := observations.NewController(obsService, rootLogger)
governorCtrl := governor.NewController(governorService, rootLogger)
@ -55,6 +59,9 @@ func RegisterRoutes(
api.Get("/ready", infrastructureCtrl.ReadyCheck)
api.Get("/version", infrastructureCtrl.Version)
// accounts resource
api.Get("/address/:id", addressCtrl.FindById)
// analytics
api.Get("/last-txs", transactionCtrl.GetLastTransactions)
api.Get("/x-chain-activity", transactionCtrl.GetChainActivity)

View File

@ -221,18 +221,23 @@ func (h *Handler) GovernorGetEnqueuedVAAs(ctx context.Context, _ *publicrpcv1.Go
// GovernorIsVAAEnqueued check if a vaa is enqueued.
func (h *Handler) GovernorIsVAAEnqueued(ctx context.Context, request *publicrpcv1.GovernorIsVAAEnqueuedRequest) (*publicrpcv1.GovernorIsVAAEnqueuedResponse, error) {
if request.MessageId == nil {
return nil, status.Error(codes.InvalidArgument, "Parameters are required")
}
chainID := vaa.ChainID(request.MessageId.EmitterChain)
emitterAddress, err := types.StringToAddress(request.MessageId.EmitterAddress)
emitterAddress, err := types.StringToAddress(request.MessageId.EmitterAddress, false /*acceptSolanaFormat*/)
if err != nil {
return nil, status.Error(codes.InvalidArgument, "Invalid emitter address")
}
isEnqueued, err := h.govSrv.IsVaaEnqueued(ctx, chainID, emitterAddress, strconv.FormatUint(request.MessageId.Sequence, 10))
if err != nil {
return nil, err
}
return &publicrpcv1.GovernorIsVAAEnqueuedResponse{IsEnqueued: isEnqueued}, nil
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/gagliardetto/solana-go"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
)
@ -25,8 +26,23 @@ func BytesToAddress(b []byte) (*Address, error) {
}
// StringToAddress converts a hex-encoded address string into an *Address.
func StringToAddress(s string) (*Address, error) {
func StringToAddress(s string, acceptSolanaFormat bool) (*Address, error) {
// Attempt to parse a Solana address (i.e.: 32 bytes encoded as base58).
// If it fails, fall back to parsing a regular Wormhole address.
if acceptSolanaFormat {
sig, err := solana.PublicKeyFromBase58(s)
if err == nil {
// This step is not expected to fail, since Solana and Wormhole addresses have the same size.
emitter, err := BytesToAddress(sig[:])
if err == nil {
return emitter, nil
}
}
}
// Attempt to parse a regular Wormhole address (i.e.: 32 bytes encoded as hex).
a, err := vaa.StringToAddress(s)
if err != nil {
return nil, err

View File

@ -6,9 +6,10 @@ import "testing"
func Test_Address_ShortString(t *testing.T) {
testCases := []struct {
Input string
Hex string
ShortHex string
Input string
AcceptSolanaFormat bool
Hex string
ShortHex string
}{
{
Input: "0x000000000000000000000000f890982f9310df57d00f659cf4fd87e65aded8d7",
@ -40,12 +41,31 @@ func Test_Address_ShortString(t *testing.T) {
Hex: "ec7372995d5cc8732397fb0ad35c0121e0eaa90d26f828a534cab54391b3a4f5",
ShortHex: "ec7372995d5cc8732397fb0ad35c0121e0eaa90d26f828a534cab54391b3a4f5",
},
{
Input: "31Sof5r1xi7dfcaz4x9Kuwm8J9ueAdDduMcme59sP8gc",
AcceptSolanaFormat: true,
Hex: "1dd48d0ee1fe7059b2866507b84f5f4259d7408c812e88bd6260a4914f7a2605",
ShortHex: "1dd48d0ee1fe7059b2866507b84f5f4259d7408c812e88bd6260a4914f7a2605",
},
{
Input: "31Sof5r1xi7dfcaz4x9Kuwm8J9ueAdDduMcme59sP8gc",
AcceptSolanaFormat: false,
},
}
for i := range testCases {
tc := &testCases[i]
addr, err := StringToAddress(tc.Input)
addr, err := StringToAddress(tc.Input, tc.AcceptSolanaFormat /*acceptSolanaFormat*/)
if tc.Hex == "" {
if err != nil {
continue
} else {
t.Fatalf("expected error, but got nil")
}
}
if err != nil {
t.Fatalf("failed to parse address %s: %v", tc.Input, err)
}