diff --git a/.gitignore b/.gitignore index 41c18ada..4190960e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ *.o *.so oasis-core-rosetta-gateway +tests/oasis-net-runner +tests/oasis-node +tests/oasis_core_release.tar.gz +tests/oasis-core +tests/rosetta-cli* +tests/validator-data diff --git a/Makefile b/Makefile index dbbd2902..6513ea3c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ #!/usr/bin/env gmake +OASIS_RELEASE := 20.6 +ROSETTA_CLI_RELEASE := 0.2.0 + OASIS_GO ?= go GO := env -u GOPATH $(OASIS_GO) GOLINT := env -u GOPATH golangci-lint @@ -25,6 +28,21 @@ OFF = "" ECHO = echo endif +# Check which tool to use for downloading. +HAVE_WGET := $(shell which wget > /dev/null && echo 1) +ifdef HAVE_WGET +DOWNLOAD := wget --quiet --show-progress --progress=bar:force:noscroll -O +else +HAVE_CURL := $(shell which curl > /dev/null && echo 1) +ifdef HAVE_CURL +DOWNLOAD := curl --progress-bar --location -o +else +$(error Please install wget or curl) +endif +endif + +ROOT := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) + .PHONY: all build clean fmt lint nuke test all: build @@ -34,9 +52,31 @@ build: @$(ECHO) "$(CYAN)*** Building...$(OFF)" @$(GO) build -test: +tests/oasis_core_release.tar.gz: + @$(ECHO) "$(MAGENTA)*** Downloading oasis-core release $(OASIS_RELEASE)...$(OFF)" + @$(DOWNLOAD) $@ https://github.com/oasislabs/oasis-core/releases/download/v$(OASIS_RELEASE)/oasis_core_$(OASIS_RELEASE)_linux_amd64.tar.gz + +tests/oasis-net-runner: tests/oasis_core_release.tar.gz + @$(ECHO) "$(MAGENTA)*** Unpacking oasis-net-runner...$(OFF)" + @tar -xf $< -C tests oasis-net-runner + +tests/oasis-node: tests/oasis_core_release.tar.gz + @$(ECHO) "$(MAGENTA)*** Unpacking oasis-node...$(OFF)" + @tar -xf $< -C tests oasis-node + +tests/rosetta-cli.tar.gz: + @$(ECHO) "$(MAGENTA)*** Downloading rosetta-cli release $(ROSETTA_CLI_RELEASE)...$(OFF)" + @$(DOWNLOAD) $@ https://github.com/coinbase/rosetta-cli/archive/v$(ROSETTA_CLI_RELEASE).tar.gz + +tests/rosetta-cli: tests/rosetta-cli.tar.gz + @$(ECHO) "$(MAGENTA)*** Building rosetta-cli...$(OFF)" + @tar -xf $< -C tests + @cd tests/rosetta-cli-$(ROSETTA_CLI_RELEASE) && go build + @cp tests/rosetta-cli-$(ROSETTA_CLI_RELEASE)/rosetta-cli tests/. + +test: build tests/oasis-net-runner tests/oasis-node tests/rosetta-cli @$(ECHO) "$(CYAN)*** Running tests...$(OFF)" - @$(GO) test --timeout 2m -race -v ./... + @$(ROOT)/tests/test.sh fmt: @$(ECHO) "$(CYAN)*** Formatting code...$(OFF)" @@ -49,7 +89,11 @@ lint: clean: @$(ECHO) "$(CYAN)*** Cleaning up...$(OFF)" @$(GO) clean + @-rm -f tests/oasis_core_release.tar.gz tests/oasis-net-runner tests/oasis-node + @-rm -rf tests/oasis-core + @-rm -f tests/rosetta-cli.tar.gz tests/rosetta-cli + @-rm -rf tests/rosetta-cli-$(ROSETTA_CLI_RELEASE) tests/validator-data -nuke: +nuke: clean @$(ECHO) "$(CYAN)*** Cleaning up really well...$(OFF)" @$(GO) clean -cache -testcache -modcache diff --git a/README.md b/README.md index c20dfc9c..bcfec1c6 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ # Oasis Gateway for Rosetta + +This repository implements the [Rosetta][1] server for the [Oasis][0] network. + +To build the server: + + make + +To run tests: + + make test + +To clean-up: + + make clean + + +`make test` will automatically download the [Oasis node][0] and [rosetta-cli][2], +set up a test Oasis network, make some sample transactions, then run the +gateway and validate it using `rosetta-cli`. + +[0]: https://github.com/oasislabs/oasis-core +[1]: https://github.com/coinbase/rosetta-sdk-go +[2]: https://github.com/coinbase/rosetta-cli diff --git a/go.mod b/go.mod index 6df80263..3b6d9005 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/oasislabs/oasis-core-rosetta-gateway go 1.14 + +replace github.com/tendermint/tendermint => github.com/oasislabs/tendermint v0.33.4-oasis1 + +require ( + github.com/coinbase/rosetta-sdk-go v0.1.5 + github.com/oasislabs/oasis-core/go v0.0.0-20200507164617-35b7f62efec5 + google.golang.org/grpc v1.29.1 +) diff --git a/main.go b/main.go index 91cca4fb..cd0393b6 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,77 @@ package main -import "fmt" +import ( + "context" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/oasislabs/oasis-core/go/common/logging" + + "github.com/oasislabs/oasis-core-rosetta-gateway/oasis-client" + "github.com/oasislabs/oasis-core-rosetta-gateway/services" +) + +// GatewayPortEnvVar is the name of the environment variable that specifies +// which port the Oasis Rosetta gateway should run on. +const GatewayPortEnvVar = "OASIS_ROSETTA_GATEWAY_PORT" + +var logger = logging.GetLogger("oasis-rosetta-gateway") + +// NewBlockchainRouter returns a Mux http.Handler from a collection of +// Rosetta service controllers. +func NewBlockchainRouter(oasisClient oasis_client.OasisClient) http.Handler { + networkAPIController := server.NewNetworkAPIController(services.NewNetworkAPIService(oasisClient)) + accountAPIController := server.NewAccountAPIController(services.NewAccountAPIService(oasisClient)) + blockAPIController := server.NewBlockAPIController(services.NewBlockAPIService(oasisClient)) + constructionAPIController := server.NewConstructionAPIController(services.NewConstructionAPIService(oasisClient)) + + return server.NewRouter(networkAPIController, accountAPIController, blockAPIController, constructionAPIController) +} func main() { - fmt.Println("Hello, world.") + // Get server port from environment variable or use the default. + port := os.Getenv(GatewayPortEnvVar) + if port == "" { + port = "8080" + } + nPort, err := strconv.Atoi(port) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Malformed %s environment variable: %v\n", GatewayPortEnvVar, err) + os.Exit(1) + } + + // Prepare a new Oasis gRPC client. + oasisClient, err := oasis_client.New() + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to prepare Oasis gRPC client: %v\n", err) + os.Exit(1) + } + + // Make a test request using the client to see if the node works. + cid, err := oasisClient.GetChainID(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Node connectivity error: %v\n", err) + os.Exit(1) + } + + // Initialize logging. + err = logging.Initialize(os.Stdout, logging.FmtLogfmt, logging.LevelDebug, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Unable to initialize logging: %v\n", err) + os.Exit(1) + } + + logger.Info("Connected to Oasis node", "chain_context", cid) + + // Start the server. + router := NewBlockchainRouter(oasisClient) + logger.Info("Oasis Rosetta Gateway listening", "port", nPort) + err = http.ListenAndServe(fmt.Sprintf(":%d", nPort), router) + if err != nil { + fmt.Fprintf(os.Stderr, "Oasis Rosetta Gateway server exited with error: %v\n", err) + os.Exit(1) + } } diff --git a/oasis-client/oasis-client.go b/oasis-client/oasis-client.go new file mode 100644 index 00000000..33e2c96f --- /dev/null +++ b/oasis-client/oasis-client.go @@ -0,0 +1,243 @@ +package oasis_client + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sync" + + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + + "github.com/oasislabs/oasis-core/go/common/crypto/signature" + cmnGrpc "github.com/oasislabs/oasis-core/go/common/grpc" + "github.com/oasislabs/oasis-core/go/common/logging" + consensus "github.com/oasislabs/oasis-core/go/consensus/api" + "github.com/oasislabs/oasis-core/go/consensus/api/transaction" + staking "github.com/oasislabs/oasis-core/go/staking/api" +) + +// LatestHeight can be used as the height in queries to specify the latest height. +const LatestHeight = consensus.HeightLatest + +// GenesisHeight is the height of the genesis block. +const GenesisHeight = int64(1) + +// GrpcAddrEnvVar is the name of the environment variable that specifies +// the gRPC host address of the Oasis node that the client should connect to. +const GrpcAddrEnvVar = "OASIS_NODE_GRPC_ADDR" + +var logger = logging.GetLogger("oasis-client") + +// OasisClient can be used to query an Oasis node for information +// and to submit transactions. +type OasisClient interface { + // GetChainID returns the network chain context, derived from the + // genesis document. + GetChainID(ctx context.Context) (string, error) + + // GetBlock returns the Oasis block at given height. + GetBlock(ctx context.Context, height int64) (*OasisBlock, error) + + // GetLatestBlock returns latest Oasis block. + GetLatestBlock(ctx context.Context) (*OasisBlock, error) + + // GetGenesisBlock returns the Oasis genesis block. + GetGenesisBlock(ctx context.Context) (*OasisBlock, error) + + // GetAccount returns the Oasis staking account for given owner + // at given height. + GetAccount(ctx context.Context, height int64, owner signature.PublicKey) (*staking.Account, error) + + // GetStakingEvents returns Oasis staking events at given height. + GetStakingEvents(ctx context.Context, height int64) ([]staking.Event, error) + + // SubmitTx submits the given JSON-encoded transaction to the node. + SubmitTx(ctx context.Context, txRaw string) error + + // GetNextNonce returns the nonce that should be used when signing the + // next transaction for the given account ID at given height. + GetNextNonce(ctx context.Context, id signature.PublicKey, height int64) (uint64, error) +} + +// OasisBlock is a representation of the Oasis block metadata, +// converted to be more compatible with the Rosetta API. +type OasisBlock struct { + Height int64 // Block height. + Hash string // Block hash. + Timestamp int64 // UNIX time, converted to milliseconds. + ParentHeight int64 // Height of parent block. + ParentHash string // Hash of parent block. +} + +// grpcOasisClient is an implementation of OasisClient using gRPC. +type grpcOasisClient struct { + sync.RWMutex + + // Connection to an Oasis node's internal socket. + grpcConn *grpc.ClientConn + + // Cached chain ID. + chainID string +} + +// connect() returns a gRPC connection to Oasis node via its internal socket. +// The connection is cached in the grpcOasisClient struct and re-established +// automatically by this method if it gets shut down. +func (oc *grpcOasisClient) connect(ctx context.Context) (*grpc.ClientConn, error) { + oc.Lock() + defer oc.Unlock() + + // Check if the existing connection is good. + if oc.grpcConn != nil && oc.grpcConn.GetState() != connectivity.Shutdown { + // Return existing connection. + return oc.grpcConn, nil + } else { + // Connection needs to be re-established. + oc.grpcConn = nil + } + + // Get gRPC host address from environment variable. + grpcAddr := os.Getenv(GrpcAddrEnvVar) + if grpcAddr == "" { + return nil, fmt.Errorf("%s environment variable not specified", GrpcAddrEnvVar) + } + + // Establish new gRPC connection. + var err error + logger.Debug("Establishing connection", "grpc_addr", grpcAddr) + oc.grpcConn, err = cmnGrpc.Dial(grpcAddr, grpc.WithInsecure()) + if err != nil { + logger.Debug("Failed to establish connection", + "grpc_addr", grpcAddr, + "err", err, + ) + return nil, fmt.Errorf("failed to dial gRPC connection to '%s': %v", grpcAddr, err) + } + return oc.grpcConn, nil +} + +func (oc *grpcOasisClient) GetChainID(ctx context.Context) (string, error) { + // Return cached chain ID if we already have it. + oc.RLock() + cid := oc.chainID + oc.RUnlock() + if cid != "" { + return cid, nil + } + + conn, err := oc.connect(ctx) + if err != nil { + return "", err + } + + oc.Lock() + defer oc.Unlock() + + client := consensus.NewConsensusClient(conn) + genesis, err := client.GetGenesisDocument(ctx) + if err != nil { + logger.Debug("GetChainID: failed to get genesis document", "err", err) + return "", err + } + oc.chainID = genesis.ChainContext() + return oc.chainID, nil +} + +func (oc *grpcOasisClient) GetBlock(ctx context.Context, height int64) (*OasisBlock, error) { + conn, err := oc.connect(ctx) + if err != nil { + return nil, err + } + client := consensus.NewConsensusClient(conn) + blk, err := client.GetBlock(ctx, height) + if err != nil { + logger.Debug("GetBlock: failed to get block", + "height", height, + "err", err, + ) + return nil, err + } + parentHeight := blk.Height - 1 + var parentHash []byte + if parentHeight <= 0 { + parentHeight = GenesisHeight + } + + parentBlk, err := client.GetBlock(ctx, parentHeight) + if err != nil { + return nil, err + } + parentHeight = parentBlk.Height + parentHash = parentBlk.Hash + + return &OasisBlock{ + Height: blk.Height, + Hash: hex.EncodeToString(blk.Hash), + Timestamp: blk.Time.UnixNano() / 1000000, // ms + ParentHeight: parentHeight, + ParentHash: hex.EncodeToString(parentHash), + }, nil +} + +func (oc *grpcOasisClient) GetLatestBlock(ctx context.Context) (*OasisBlock, error) { + return oc.GetBlock(ctx, consensus.HeightLatest) +} + +func (oc *grpcOasisClient) GetGenesisBlock(ctx context.Context) (*OasisBlock, error) { + return oc.GetBlock(ctx, GenesisHeight) +} + +func (oc *grpcOasisClient) GetAccount(ctx context.Context, height int64, owner signature.PublicKey) (*staking.Account, error) { + conn, err := oc.connect(ctx) + if err != nil { + return nil, err + } + client := staking.NewStakingClient(conn) + return client.AccountInfo(ctx, &staking.OwnerQuery{ + Height: height, + Owner: owner, + }) +} + +func (oc *grpcOasisClient) GetStakingEvents(ctx context.Context, height int64) ([]staking.Event, error) { + conn, err := oc.connect(ctx) + if err != nil { + return nil, err + } + client := staking.NewStakingClient(conn) + return client.GetEvents(ctx, height) +} + +func (oc *grpcOasisClient) SubmitTx(ctx context.Context, txRaw string) error { + conn, err := oc.connect(ctx) + if err != nil { + return err + } + client := consensus.NewConsensusClient(conn) + var tx *transaction.SignedTransaction + if err := json.Unmarshal([]byte(txRaw), &tx); err != nil { + logger.Debug("SubmitTx: failed to unmarshal raw transaction", "err", err) + return err + } + return client.SubmitTx(ctx, tx) +} + +func (oc *grpcOasisClient) GetNextNonce(ctx context.Context, id signature.PublicKey, height int64) (uint64, error) { + conn, err := oc.connect(ctx) + if err != nil { + return 0, err + } + client := consensus.NewConsensusClient(conn) + return client.GetSignerNonce(ctx, &consensus.GetSignerNonceRequest{ + ID: id, + Height: height, + }) +} + +// New creates a new Oasis gRPC client. +func New() (OasisClient, error) { + return &grpcOasisClient{}, nil +} diff --git a/services/account.go b/services/account.go new file mode 100644 index 00000000..5ed0ab04 --- /dev/null +++ b/services/account.go @@ -0,0 +1,148 @@ +package services + +import ( + "context" + "encoding/json" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" + + oc "github.com/oasislabs/oasis-core-rosetta-gateway/oasis-client" + "github.com/oasislabs/oasis-core/go/common/crypto/signature" + "github.com/oasislabs/oasis-core/go/common/logging" +) + +// SubAccountGeneral specifies the name of the general subaccount. +const SubAccountGeneral = "general" + +// SubAccountEscrow specifies the name of the escrow subaccount. +const SubAccountEscrow = "escrow" + +var loggerAcct = logging.GetLogger("services/account") + +type accountAPIService struct { + oasisClient oc.OasisClient +} + +// NewAccountAPIService creates a new instance of an AccountAPIService. +func NewAccountAPIService(oasisClient oc.OasisClient) server.AccountAPIServicer { + return &accountAPIService{ + oasisClient: oasisClient, + } +} + +// AccountBalance implements the /account/balance endpoint. +func (s *accountAPIService) AccountBalance( + ctx context.Context, + request *types.AccountBalanceRequest, +) (*types.AccountBalanceResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerAcct.Error("AccountBalance: network validation failed", "err", terr.Message) + return nil, terr + } + + height := oc.LatestHeight + + if request.BlockIdentifier != nil { + if request.BlockIdentifier.Index != nil { + height = *request.BlockIdentifier.Index + } else if request.BlockIdentifier.Hash != nil { + loggerAcct.Error("AccountBalance: must query block by index") + return nil, ErrMustQueryByIndex + } + } + + if request.AccountIdentifier.Address == "" { + loggerAcct.Error("AccountBalance: invalid account address (empty)") + return nil, ErrInvalidAccountAddress + } + + var owner signature.PublicKey + err := owner.UnmarshalText([]byte(request.AccountIdentifier.Address)) + if err != nil { + loggerAcct.Error("AccountBalance: invalid account address", "err", err) + return nil, ErrInvalidAccountAddress + } + + if request.AccountIdentifier.SubAccount == nil { + loggerAcct.Error("AccountBalance: invalid sub-account (empty)") + return nil, ErrMustSpecifySubAccount + } else { + switch request.AccountIdentifier.SubAccount.Address { + case SubAccountGeneral: + case SubAccountEscrow: + default: + loggerAcct.Error("AccountBalance: invalid sub-account", "subaccount", request.AccountIdentifier.SubAccount.Address) + return nil, ErrMustSpecifySubAccount + } + } + + act, err := s.oasisClient.GetAccount(ctx, height, owner) + if err != nil { + loggerAcct.Error("AccountBalance: unable to get account", + "account_id", owner.String(), + "height", height, + "err", err, + ) + return nil, ErrUnableToGetAccount + } + + blk, err := s.oasisClient.GetBlock(ctx, height) + if err != nil { + loggerAcct.Error("AccountBalance: unable to get block", + "height", height, + "err", err, + ) + return nil, ErrUnableToGetBlk + } + + md := make(map[string]interface{}) + md[NonceKey] = act.General.Nonce + + var value string + switch request.AccountIdentifier.SubAccount.Address { + case SubAccountGeneral: + value = act.General.Balance.String() + case SubAccountEscrow: + // Total is Active + Debonding. + total := act.Escrow.Active.Balance.Clone() + if err := total.Add(&act.Escrow.Debonding.Balance); err != nil { + loggerAcct.Error("AccountBalance: escrow: unable to add debonding to active", + "account_id", owner.String(), + "height", height, + "escrow_active_balance", act.Escrow.Active.Balance.String(), + "escrow_debonding_balance", act.Escrow.Debonding.Balance.String(), + "err", err, + ) + return nil, ErrMalformedValue + } + value = total.String() + default: + // This shouldn't happen, since we already check for this above. + return nil, ErrMustSpecifySubAccount + } + + resp := &types.AccountBalanceResponse{ + BlockIdentifier: &types.BlockIdentifier{ + Index: blk.Height, + Hash: blk.Hash, + }, + Balances: []*types.Amount{ + &types.Amount{ + Value: value, + Currency: OasisCurrency, + }, + }, + Metadata: &md, + } + + jr, _ := json.Marshal(resp) + loggerAcct.Debug("AccountBalance OK", + "response", jr, + "account_id", owner.String(), + "subaccount", request.AccountIdentifier.SubAccount.Address, + ) + + return resp, nil +} diff --git a/services/block.go b/services/block.go new file mode 100644 index 00000000..6fcc393d --- /dev/null +++ b/services/block.go @@ -0,0 +1,318 @@ +package services + +import ( + "context" + "encoding/json" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" + + oc "github.com/oasislabs/oasis-core-rosetta-gateway/oasis-client" + "github.com/oasislabs/oasis-core/go/common/logging" +) + +// OpTransferFrom is the first part of the Oasis Transfer operation. +const OpTransferFrom = "TransferFrom" + +// OpTransferTo is the second part of the Oasis Transfer operation. +const OpTransferTo = "TransferTo" + +// OpBurn is the Oasis Burn operation. +const OpBurn = "Burn" + +// OpStatusOK is the OK status. +const OpStatusOK = "OK" + +var loggerBlk = logging.GetLogger("services/block") + +type blockAPIService struct { + oasisClient oc.OasisClient +} + +// NewBlockAPIService creates a new instance of an AccountAPIService. +func NewBlockAPIService(oasisClient oc.OasisClient) server.BlockAPIServicer { + return &blockAPIService{ + oasisClient: oasisClient, + } +} + +// Block implements the /block endpoint. +func (s *blockAPIService) Block( + ctx context.Context, + request *types.BlockRequest, +) (*types.BlockResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerBlk.Error("Block: network validation failed", "err", terr.Message) + return nil, terr + } + + height := oc.LatestHeight + + if request.BlockIdentifier != nil { + if request.BlockIdentifier.Index != nil { + height = *request.BlockIdentifier.Index + } else if request.BlockIdentifier.Hash != nil { + loggerBlk.Error("Block: must query block by index") + return nil, ErrMustQueryByIndex + } + } + + blk, err := s.oasisClient.GetBlock(ctx, height) + if err != nil { + loggerBlk.Error("Block: unable to get block", + "height", height, + "err", err, + ) + return nil, ErrUnableToGetBlk + } + + evts, err := s.oasisClient.GetStakingEvents(ctx, height) + if err != nil { + loggerBlk.Error("Block: unable to get transactions", + "height", height, + "err", err, + ) + return nil, ErrUnableToGetTxns + } + + txns := []*types.Transaction{} + for _, evt := range evts { + switch { + case evt.TransferEvent != nil: + txns = append(txns, &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: evt.TxHash.String(), + }, + Operations: []*types.Operation{ + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: OpTransferFrom, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: evt.TransferEvent.From.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountGeneral, + }, + }, + Amount: &types.Amount{ + Value: "-" + evt.TransferEvent.Tokens.String(), + Currency: OasisCurrency, + }, + }, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + RelatedOperations: []*types.OperationIdentifier{ + &types.OperationIdentifier{ + Index: 0, + }, + }, + Type: OpTransferTo, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: evt.TransferEvent.To.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountGeneral, + }, + }, + Amount: &types.Amount{ + Value: evt.TransferEvent.Tokens.String(), + Currency: OasisCurrency, + }, + }, + }, + }) + case evt.BurnEvent != nil: + txns = append(txns, &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: evt.TxHash.String(), + }, + Operations: []*types.Operation{ + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: OpBurn, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: evt.BurnEvent.Owner.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountGeneral, + }, + }, + Amount: &types.Amount{ + Value: "-" + evt.BurnEvent.Tokens.String(), + Currency: OasisCurrency, + }, + }, + }, + }) + case evt.EscrowEvent != nil: + ee := evt.EscrowEvent + // Note: These have been abstracted to use Transfer* and Burn + // instead of creating new operations just for escrow accounts. + // It should be evident based on the subaccount identifiers + // if an operation is escrow-related or not. + switch { + case ee.Add != nil: + // Owner's general account -> escrow account. + txns = append(txns, &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: evt.TxHash.String(), + }, + Operations: []*types.Operation{ + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: OpTransferFrom, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: ee.Add.Owner.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountGeneral, + }, + }, + Amount: &types.Amount{ + Value: "-" + ee.Add.Tokens.String(), + Currency: OasisCurrency, + }, + }, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + RelatedOperations: []*types.OperationIdentifier{ + &types.OperationIdentifier{ + Index: 0, + }, + }, + Type: OpTransferTo, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: ee.Add.Escrow.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: ee.Add.Tokens.String(), + Currency: OasisCurrency, + }, + }, + }, + }) + case ee.Take != nil: + txns = append(txns, &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: evt.TxHash.String(), + }, + Operations: []*types.Operation{ + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: OpBurn, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: ee.Take.Owner.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: "-" + ee.Take.Tokens.String(), + Currency: OasisCurrency, + }, + }, + }, + }) + case ee.Reclaim != nil: + // Escrow account -> owner's general account. + txns = append(txns, &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: evt.TxHash.String(), + }, + Operations: []*types.Operation{ + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: OpTransferFrom, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: ee.Reclaim.Escrow.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountEscrow, + }, + }, + Amount: &types.Amount{ + Value: "-" + ee.Reclaim.Tokens.String(), + Currency: OasisCurrency, + }, + }, + &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + RelatedOperations: []*types.OperationIdentifier{ + &types.OperationIdentifier{ + Index: 0, + }, + }, + Type: OpTransferTo, + Status: OpStatusOK, + Account: &types.AccountIdentifier{ + Address: ee.Reclaim.Owner.String(), + SubAccount: &types.SubAccountIdentifier{ + Address: SubAccountGeneral, + }, + }, + Amount: &types.Amount{ + Value: ee.Reclaim.Tokens.String(), + Currency: OasisCurrency, + }, + }, + }, + }) + } + } + } + + tblk := &types.Block{ + BlockIdentifier: &types.BlockIdentifier{ + Index: blk.Height, + Hash: blk.Hash, + }, + ParentBlockIdentifier: &types.BlockIdentifier{ + Index: blk.ParentHeight, + Hash: blk.ParentHash, + }, + Timestamp: blk.Timestamp, + Transactions: txns, + } + + resp := &types.BlockResponse{ + Block: tblk, + } + + jr, _ := json.Marshal(resp) + loggerBlk.Debug("Block OK", "response", jr) + + return resp, nil +} + +// BlockTransaction implements the /block/transaction endpoint. +// Note: we don't implement this, since we already return all transactions +// in the /block endpoint reponse above. +func (s *blockAPIService) BlockTransaction( + ctx context.Context, + request *types.BlockTransactionRequest, +) (*types.BlockTransactionResponse, *types.Error) { + loggerBlk.Error("BlockTransaction: not implemented") + return nil, ErrNotImplemented +} diff --git a/services/common.go b/services/common.go new file mode 100644 index 00000000..6022ca5b --- /dev/null +++ b/services/common.go @@ -0,0 +1,49 @@ +package services + +import ( + "context" + + "github.com/coinbase/rosetta-sdk-go/types" + + oc "github.com/oasislabs/oasis-core-rosetta-gateway/oasis-client" +) + +// OasisBlockchainName is the name of the Oasis blockchain. +const OasisBlockchainName = "Oasis" + +// OasisCurrency is the currency used on the Oasis blockchain. +var OasisCurrency = &types.Currency{ + Symbol: "ROSE", + Decimals: 9, +} + +// GetChainID returns the chain ID. +func GetChainID(ctx context.Context, oc oc.OasisClient) (string, *types.Error) { + chainID, err := oc.GetChainID(ctx) + if err != nil { + return "", ErrUnableToGetChainID + } + return chainID, nil +} + +// ValidateNetworkIdentifier validates the network identifier. +func ValidateNetworkIdentifier(ctx context.Context, oc oc.OasisClient, ni *types.NetworkIdentifier) *types.Error { + if ni != nil { + if ni.Blockchain != OasisBlockchainName { + return ErrInvalidBlockchain + } + if ni.SubNetworkIdentifier != nil { + return ErrInvalidSubnetwork + } + chainID, err := GetChainID(ctx, oc) + if err != nil { + return err + } + if ni.Network != chainID { + return ErrInvalidNetwork + } + } else { + return ErrMissingNID + } + return nil +} diff --git a/services/construction.go b/services/construction.go new file mode 100644 index 00000000..3076505b --- /dev/null +++ b/services/construction.go @@ -0,0 +1,126 @@ +package services + +import ( + "context" + "encoding/json" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" + + oc "github.com/oasislabs/oasis-core-rosetta-gateway/oasis-client" + "github.com/oasislabs/oasis-core/go/common/crypto/hash" + "github.com/oasislabs/oasis-core/go/common/crypto/signature" + "github.com/oasislabs/oasis-core/go/common/logging" +) + +// OptionsIDKey is the name of the key in the Options map inside a +// ConstructionMetadataRequest that specifies the account ID. +const OptionsIDKey = "id" + +// NonceKey is the name of the key in the Metadata map inside a +// ConstructionMetadataResponse that specifies the next valid nonce. +const NonceKey = "nonce" + +var loggerCons = logging.GetLogger("services/construction") + +type constructionAPIService struct { + oasisClient oc.OasisClient +} + +// NewConstructionAPIService creates a new instance of an ConstructionAPIService. +func NewConstructionAPIService(oasisClient oc.OasisClient) server.ConstructionAPIServicer { + return &constructionAPIService{ + oasisClient: oasisClient, + } +} + +// ConstructionMetadata implements the /construction/metadata endpoint. +func (s *constructionAPIService) ConstructionMetadata( + ctx context.Context, + request *types.ConstructionMetadataRequest, +) (*types.ConstructionMetadataResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionMetadata: network validation failed", "err", terr.Message) + return nil, terr + } + + // Get the account ID field from the Options object. + if request.Options == nil { + loggerCons.Error("ConstructionMetadata: missing options") + return nil, ErrInvalidAccountAddress + } + idRaw, ok := (*request.Options)[OptionsIDKey] + if !ok { + loggerCons.Error("ConstructionMetadata: account ID field not given") + return nil, ErrInvalidAccountAddress + } + idString, ok := idRaw.(string) + if !ok { + loggerCons.Error("ConstructionMetadata: malformed account ID field") + return nil, ErrInvalidAccountAddress + } + + // Convert the byte value of the ID to account address. + var owner signature.PublicKey + err := owner.UnmarshalText([]byte(idString)) + if err != nil { + loggerCons.Error("ConstructionMetadata: invalid account ID", "err", err) + return nil, ErrInvalidAccountAddress + } + + nonce, err := s.oasisClient.GetNextNonce(ctx, owner, oc.LatestHeight) + if err != nil { + loggerCons.Error("ConstructionMetadata: unable to get next nonce", + "account_id", owner.String(), + "err", err, + ) + return nil, ErrUnableToGetNextNonce + } + + // Return next nonce that should be used to sign transactions for given account. + md := make(map[string]interface{}) + md[NonceKey] = nonce + + resp := &types.ConstructionMetadataResponse{ + Metadata: &md, + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionMetadata OK", "response", jr) + + return resp, nil +} + +// ConstructionSubmit implements the /construction/submit endpoint. +func (s *constructionAPIService) ConstructionSubmit( + ctx context.Context, + request *types.ConstructionSubmitRequest, +) (*types.ConstructionSubmitResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerCons.Error("ConstructionSubmit: network validation failed", "err", terr.Message) + return nil, terr + } + + if err := s.oasisClient.SubmitTx(ctx, request.SignedTransaction); err != nil { + loggerCons.Error("ConstructionSubmit: SubmitTx failed", "err", err) + return nil, ErrUnableToSubmitTx + } + + // TODO: Does this match the hashes we actually use in consensus? + var h hash.Hash + h.From(request.SignedTransaction) + txID := h.String() + + resp := &types.ConstructionSubmitResponse{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: txID, + }, + } + + jr, _ := json.Marshal(resp) + loggerCons.Debug("ConstructionSubmit OK", "response", jr) + + return resp, nil +} diff --git a/services/errors.go b/services/errors.go new file mode 100644 index 00000000..04d6b046 --- /dev/null +++ b/services/errors.go @@ -0,0 +1,127 @@ +package services + +import "github.com/coinbase/rosetta-sdk-go/types" + +var ( + ErrUnableToGetChainID = &types.Error{ + Code: 1, + Message: "unable to get chain ID", + Retriable: true, + } + + ErrInvalidBlockchain = &types.Error{ + Code: 2, + Message: "invalid blockchain specified in network identifier", + Retriable: false, + } + + ErrInvalidSubnetwork = &types.Error{ + Code: 3, + Message: "invalid sub-network identifier", + Retriable: false, + } + + ErrInvalidNetwork = &types.Error{ + Code: 4, + Message: "invalid network specified in network identifier", + Retriable: false, + } + + ErrMissingNID = &types.Error{ + Code: 5, + Message: "network identifier is missing", + Retriable: false, + } + + ErrUnableToGetLatestBlk = &types.Error{ + Code: 6, + Message: "unable to get latest block", + Retriable: true, + } + + ErrUnableToGetGenesisBlk = &types.Error{ + Code: 7, + Message: "unable to get genesis block", + Retriable: true, + } + + ErrUnableToGetAccount = &types.Error{ + Code: 8, + Message: "unable to get account", + Retriable: true, + } + + ErrMustQueryByIndex = &types.Error{ + Code: 9, + Message: "blocks must be queried by index and not hash", + Retriable: false, + } + + ErrInvalidAccountAddress = &types.Error{ + Code: 10, + Message: "invalid account address", + Retriable: false, + } + + ErrMustSpecifySubAccount = &types.Error{ + Code: 11, + Message: "a valid subaccount must be specified ('general' or 'escrow')", + Retriable: false, + } + + ErrUnableToGetBlk = &types.Error{ + Code: 12, + Message: "unable to get block", + Retriable: true, + } + + ErrNotImplemented = &types.Error{ + Code: 13, + Message: "operation not implemented", + Retriable: false, + } + + ErrUnableToGetTxns = &types.Error{ + Code: 14, + Message: "unable to get transactions", + Retriable: true, + } + + ErrUnableToSubmitTx = &types.Error{ + Code: 15, + Message: "unable to submit transaction", + Retriable: false, + } + + ErrUnableToGetNextNonce = &types.Error{ + Code: 16, + Message: "unable to get next nonce", + Retriable: true, + } + + ErrMalformedValue = &types.Error{ + Code: 17, + Message: "malformed value", + Retriable: false, + } + + ERROR_LIST = []*types.Error{ + ErrUnableToGetChainID, + ErrInvalidBlockchain, + ErrInvalidSubnetwork, + ErrInvalidNetwork, + ErrMissingNID, + ErrUnableToGetLatestBlk, + ErrUnableToGetGenesisBlk, + ErrUnableToGetAccount, + ErrMustQueryByIndex, + ErrInvalidAccountAddress, + ErrMustSpecifySubAccount, + ErrUnableToGetBlk, + ErrNotImplemented, + ErrUnableToGetTxns, + ErrUnableToSubmitTx, + ErrUnableToGetNextNonce, + ErrMalformedValue, + } +) diff --git a/services/network.go b/services/network.go new file mode 100644 index 00000000..e15966c1 --- /dev/null +++ b/services/network.go @@ -0,0 +1,126 @@ +package services + +import ( + "context" + "encoding/json" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" + + oc "github.com/oasislabs/oasis-core-rosetta-gateway/oasis-client" + "github.com/oasislabs/oasis-core/go/common/logging" +) + +var loggerNet = logging.GetLogger("services/network") + +type networkAPIService struct { + oasisClient oc.OasisClient +} + +// NewNetworkAPIService creates a new instance of a NetworkAPIService. +func NewNetworkAPIService(oasisClient oc.OasisClient) server.NetworkAPIServicer { + return &networkAPIService{ + oasisClient: oasisClient, + } +} + +// NetworkList implements the /network/list endpoint. +func (s *networkAPIService) NetworkList( + ctx context.Context, + request *types.MetadataRequest, +) (*types.NetworkListResponse, *types.Error) { + chainID, err := GetChainID(ctx, s.oasisClient) + if err != nil { + loggerNet.Error("NetworkList: unable to get chain ID") + return nil, err + } + + resp := &types.NetworkListResponse{ + NetworkIdentifiers: []*types.NetworkIdentifier{ + &types.NetworkIdentifier{ + Blockchain: OasisBlockchainName, + Network: chainID, + }, + }, + } + + jr, _ := json.Marshal(resp) + loggerNet.Debug("NetworkList OK", "response", jr) + + return resp, nil +} + +// NetworkStatus implements the /network/status endpoint. +func (s *networkAPIService) NetworkStatus( + ctx context.Context, + request *types.NetworkRequest, +) (*types.NetworkStatusResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerNet.Error("NetworkStatus: network validation failed", "err", terr.Message) + return nil, terr + } + + blk, err := s.oasisClient.GetLatestBlock(ctx) + if err != nil { + loggerNet.Error("NetworkStatus: unable to get latest block", "err", err) + return nil, ErrUnableToGetLatestBlk + } + + genBlk, err := s.oasisClient.GetGenesisBlock(ctx) + if err != nil { + loggerNet.Error("NetworkStatus: unable to get genesis block", "err", err) + return nil, ErrUnableToGetGenesisBlk + } + + resp := &types.NetworkStatusResponse{ + CurrentBlockIdentifier: &types.BlockIdentifier{ + Index: blk.Height, + Hash: blk.Hash, + }, + CurrentBlockTimestamp: blk.Timestamp, + GenesisBlockIdentifier: &types.BlockIdentifier{ + Index: genBlk.Height, + Hash: genBlk.Hash, + }, + Peers: []*types.Peer{}, // TODO + } + + jr, _ := json.Marshal(resp) + loggerNet.Debug("NetworkStatus OK", "response", jr) + + return resp, nil +} + +// NetworkOptions implements the /network/options endpoint. +func (s *networkAPIService) NetworkOptions( + ctx context.Context, + request *types.NetworkRequest, +) (*types.NetworkOptionsResponse, *types.Error) { + terr := ValidateNetworkIdentifier(ctx, s.oasisClient, request.NetworkIdentifier) + if terr != nil { + loggerNet.Error("NetworkStatus: network validation failed", "err", terr.Message) + return nil, terr + } + + return &types.NetworkOptionsResponse{ + Version: &types.Version{ + RosettaVersion: "1.3.5", + NodeVersion: "20.6", + }, + Allow: &types.Allow{ + OperationStatuses: []*types.OperationStatus{ + { + Status: OpStatusOK, + Successful: true, + }, + }, + OperationTypes: []string{ + OpTransferFrom, + OpTransferTo, + OpBurn, + }, + Errors: ERROR_LIST, + }, + }, nil +} diff --git a/tests/test-fixture-config.json b/tests/test-fixture-config.json new file mode 100644 index 00000000..e684fb49 --- /dev/null +++ b/tests/test-fixture-config.json @@ -0,0 +1,60 @@ +{ + "tee": { + "hardware": 0, + "mr_signer": null + }, + "network": { + "node_binary": "oasis-node", + "consensus_backend": "", + "consensus_timeout_commit": 1000000000, + "consensus_gas_costs_tx_byte": 0, + "halt_epoch": 18446744073709551615, + "epochtime_mock": true, + "epochtime_tendermint_interval": 0, + "deterministic_identities": false, + "staking_genesis": "test-staking-genesis.json" + }, + "entities": [ + { + "IsDebugTestEntity": true, + "Restore": false + }, + { + "IsDebugTestEntity": false, + "Restore": false + } + ], + "runtimes": [ + ], + "validators": [ + { + "allow_early_termination": false, + "allow_error_termination": false, + "entity": 1, + "consensus": { + "min_gas_price": 0, + "submission_gas_price": 0, + "disable_check_tx": false, + "prune_num_kept": 0, + "tendermint_recover_corrupted_wal": false + } + } + ], + "keymanagers": [ + ], + "storage_workers": [ + ], + "compute_workers": [ + ], + "clients": [ + { + "consensus": { + "min_gas_price": 0, + "submission_gas_price": 0, + "disable_check_tx": false, + "prune_num_kept": 0, + "tendermint_recover_corrupted_wal": false + } + } + ] +} diff --git a/tests/test-staking-genesis.json b/tests/test-staking-genesis.json new file mode 100644 index 00000000..fd1403bf --- /dev/null +++ b/tests/test-staking-genesis.json @@ -0,0 +1,19 @@ +{ + "params": { + "commission_schedule_rules": { + "rate_change_interval": 10, + "rate_bound_lead": 30, + "max_rate_steps": 4, + "max_bound_steps": 12 + }, + "thresholds": { + "0": "0", + "1": "0", + "2": "0", + "3": "0", + "4": "0", + "5": "0", + "6": "0" + } + } +} diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 00000000..37e29f2d --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -o nounset -o pipefail -o errexit +trap "exit 1" INT + +# Get the root directory of the tests dir inside the repository. +ROOT="$(cd $(dirname $0); pwd -P)" +cd "${ROOT}" + +# ANSI escape codes to brighten up the output. +GRN=$'\e[32;1m' +OFF=$'\e[0m' + +# Paths to various binaries and config files that we need. +OASIS_ROSETTA_GW="${ROOT}/../oasis-core-rosetta-gateway" +OASIS_NET_RUNNER="${ROOT}/oasis-net-runner" +OASIS_NODE="${ROOT}/oasis-node" +FIXTURE_FILE="${ROOT}/test-fixture-config.json" + +# Destination address for test transfers. +DST="XqUyj5Q+9vZtqu10yw6Zw7HEX3Ywe0JQA9vHyzY47TU=" + +# Kill all dangling processes on exit. +cleanup() { + printf "${OFF}" + pkill -P $$ || true + wait || true +} +trap "cleanup" EXIT + +# The base directory for all the node and test env cruft. +TEST_BASE_DIR=$(mktemp --directory --tmpdir oasis-rosetta-XXXXXXXXXX) + +# The oasis-node binary must be in the path for the oasis-net-runner to find it. +export PATH="${PATH}:${ROOT}" + +printf "${GRN}### Starting the test network...${OFF}\n" +${OASIS_NET_RUNNER} \ + --fixture.file ${FIXTURE_FILE} \ + --basedir.no_temp_dir \ + --basedir ${TEST_BASE_DIR} & + +export OASIS_NODE_GRPC_ADDR="unix:${TEST_BASE_DIR}/net-runner/network/client-0/internal.sock" + +# How many nodes to wait for each epoch. +NUM_NODES=1 + +# Current nonce for transactions (incremented after every submit_tx). +NONCE=0 + +# Helper function for advancing the current epoch to the given parameter. +advance_epoch() { + local epoch=$1 + printf "${GRN}### Advancing epoch ($epoch)...${OFF}\n" + ${OASIS_NODE} debug control set-epoch \ + --address ${OASIS_NODE_GRPC_ADDR} \ + --epoch $epoch +} + +# Helper function that waits for all nodes to register. +wait_for_nodes() { + printf "${GRN}### Waiting for all nodes to register...${OFF}\n" + ${OASIS_NODE} debug control wait-nodes \ + --address ${OASIS_NODE_GRPC_ADDR} \ + --nodes ${NUM_NODES} \ + --wait +} + +# Helper function that submits the given transaction JSON file. +submit_tx() { + local tx=$1 + # Submit transaction. + ${OASIS_NODE} consensus submit_tx \ + --transaction.file "$tx" \ + --address ${OASIS_NODE_GRPC_ADDR} \ + --debug.allow_test_keys + # Increase nonce. + NONCE=$((NONCE+1)) +} + +# Helper function that generates a transfer transaction. +gen_transfer() { + local tx=$1 + local amount=$2 + local dst=$3 + ${OASIS_NODE} stake account gen_transfer \ + --stake.amount $amount \ + --stake.transfer.destination "$dst" \ + --transaction.file "$tx" \ + --transaction.nonce ${NONCE} \ + --transaction.fee.amount 0 \ + --transaction.fee.gas 10000 \ + --debug.dont_blame_oasis \ + --debug.test_entity \ + --debug.allow_test_keys \ + --genesis.file "${TEST_BASE_DIR}/net-runner/network/genesis.json" +} + +# Helper function that generates a burn transaction. +gen_burn() { + local tx=$1 + local amount=$2 + ${OASIS_NODE} stake account gen_burn \ + --stake.amount $amount \ + --transaction.file "$tx" \ + --transaction.nonce ${NONCE} \ + --transaction.fee.amount 0 \ + --transaction.fee.gas 10000 \ + --debug.dont_blame_oasis \ + --debug.test_entity \ + --debug.allow_test_keys \ + --genesis.file "${TEST_BASE_DIR}/net-runner/network/genesis.json" +} + +printf "${GRN}### Waiting for the validator to register...${OFF}\n" +${OASIS_NODE} debug control wait-nodes \ + --address ${OASIS_NODE_GRPC_ADDR} \ + --nodes 1 \ + --wait + +advance_epoch 1 +wait_for_nodes + +printf "${GRN}### Burning tokens...${OFF}\n" +gen_burn "${TEST_BASE_DIR}/burn.json" 42 +submit_tx "${TEST_BASE_DIR}/burn.json" + +advance_epoch 2 +wait_for_nodes + +printf "${GRN}### Transferring tokens (1)...${OFF}\n" +gen_transfer "${TEST_BASE_DIR}/tx1.json" 1000 "${DST}" +submit_tx "${TEST_BASE_DIR}/tx1.json" + +advance_epoch 3 +wait_for_nodes + +printf "${GRN}### Transferring tokens (2)...${OFF}\n" +gen_transfer "${TEST_BASE_DIR}/tx2.json" 123 "${DST}" +submit_tx "${TEST_BASE_DIR}/tx2.json" + +printf "${GRN}### Transferring tokens (3)...${OFF}\n" +gen_transfer "${TEST_BASE_DIR}/tx3.json" 456 "${DST}" +submit_tx "${TEST_BASE_DIR}/tx3.json" + +advance_epoch 4 +wait_for_nodes + +advance_epoch 5 +wait_for_nodes + +advance_epoch 6 +wait_for_nodes + +printf "${GRN}### Starting the Rosetta gateway...${OFF}\n" +${OASIS_ROSETTA_GW} & + +sleep 3 + +printf "${GRN}### Validating Rosetta gateway implementation...${OFF}\n" +./rosetta-cli check --end 42 || true +rm -rf "${ROOT}/validator-data" /tmp/rosetta-cli* + +# Clean up after a successful run. +rm -rf "${TEST_BASE_DIR}" + +printf "${GRN}### Tests finished.${OFF}\n"