├── .dockerignore ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── buf_breaking.yml │ ├── buf_push.yml │ ├── build.yml │ ├── e2e.yml │ ├── lint-pr.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .markdownlint.json ├── .markdownlintignore ├── .mergify.yml ├── .mockery.yaml ├── LICENSE ├── Makefile ├── README.md ├── abci ├── preblock │ ├── oracle │ │ ├── README.md │ │ ├── preblock.go │ │ ├── preblock_test.go │ │ └── utils.go │ └── utils.go ├── proposals │ ├── README.md │ ├── errors.go │ ├── options.go │ ├── proposals.go │ ├── proposals_test.go │ └── validate.go ├── strategies │ ├── aggregator │ │ ├── errors.go │ │ ├── mocks │ │ │ ├── mock_price_applier.go │ │ │ └── mock_vote_aggregator.go │ │ ├── price_applier.go │ │ ├── price_applier_test.go │ │ ├── vote_aggregator.go │ │ └── vote_aggregator_test.go │ ├── codec │ │ ├── codec.go │ │ ├── codec_test.go │ │ └── mocks │ │ │ ├── extended_commit_codec.go │ │ │ └── vote_extension_codec.go │ └── currencypair │ │ ├── README.md │ │ ├── default.go │ │ ├── default_test.go │ │ ├── delta.go │ │ ├── delta_test.go │ │ ├── hash.go │ │ ├── hash_test.go │ │ ├── mocks │ │ ├── mock_currency_pair_strategy.go │ │ └── mock_oracle_keeper.go │ │ └── types.go ├── testutils │ └── utils.go ├── types │ ├── constants.go │ ├── errors.go │ ├── interfaces.go │ ├── mocks │ │ └── mock_oracle_keeper.go │ └── utils.go └── ve │ ├── README.md │ ├── errors.go │ ├── types │ └── vote_extensions.pb.go │ ├── utils.go │ ├── utils_test.go │ ├── vote_extension.go │ └── vote_extension_test.go ├── aggregator ├── README.md ├── aggregator.go └── options.go ├── api └── connect │ ├── abci │ └── v2 │ │ └── vote_extensions.pulsar.go │ ├── marketmap │ ├── module │ │ └── v2 │ │ │ └── module.pulsar.go │ └── v2 │ │ ├── genesis.pulsar.go │ │ ├── market.pulsar.go │ │ ├── params.pulsar.go │ │ ├── query.pulsar.go │ │ ├── query_grpc.pb.go │ │ ├── tx.pulsar.go │ │ └── tx_grpc.pb.go │ ├── oracle │ ├── module │ │ └── v2 │ │ │ └── module.pulsar.go │ └── v2 │ │ ├── genesis.pulsar.go │ │ ├── query.pulsar.go │ │ ├── query_grpc.pb.go │ │ ├── tx.pulsar.go │ │ └── tx_grpc.pb.go │ └── types │ └── v2 │ └── currency_pair.pulsar.go ├── cmd ├── build │ └── build.go ├── client │ ├── README.md │ └── main.go ├── connect │ ├── config │ │ ├── config.go │ │ └── config_test.go │ ├── main.go │ └── main_test.go ├── constants │ ├── marketmaps │ │ ├── markets.go │ │ └── markets_test.go │ └── providers.go └── vote-extensions-cli │ └── main.go ├── codecov.yml ├── contrib ├── compose │ └── docker-compose-dev.yml ├── images │ ├── connect.base.Dockerfile │ ├── connect.e2e.Dockerfile │ ├── connect.generator.dev.Dockerfile │ ├── connect.local.Dockerfile │ ├── connect.sidecar.dev.Dockerfile │ └── connect.sidecar.prod.Dockerfile └── prometheus │ └── prometheus.yml ├── docs ├── developers │ ├── connect-sdk.mdx │ ├── high-level.mdx │ ├── integration.mdx │ ├── modules │ │ └── marketmap.mdx │ └── providers.mdx ├── favicon.ico ├── favicon.png ├── img │ ├── connect-arch.png │ ├── connect-banner.png │ ├── connect-customers.png │ ├── connect-town-crier.png │ ├── docs │ │ └── validator │ │ │ ├── quotes-correct.png │ │ │ └── quotes-wrong.png │ ├── logo.svg │ ├── prepare.svg │ ├── searcher │ │ ├── allowed_bundles.png │ │ └── disallowed_bundles.png │ ├── sidecar.svg │ ├── skip-og-image.jpeg │ ├── slinky.png │ └── slinky_math.jpeg ├── introduction.mdx ├── learn │ ├── architecture.mdx │ └── security.mdx ├── logo │ ├── dark.svg │ └── light.svg ├── metrics │ ├── application-reference.mdx │ ├── oracle-reference.mdx │ ├── overview.mdx │ └── setup.mdx ├── mint.json ├── snippets │ ├── dydx-quickstart-guide.mdx │ ├── quickstart.mdx │ └── stargaze-quickstart-guide.mdx ├── style.css └── validators │ ├── advanced-setups.mdx │ ├── configuration.mdx │ ├── faq.mdx │ └── quickstart.mdx ├── go.mod ├── go.sum ├── grafana └── provisioning │ ├── dashboards │ ├── chain-dashboard.json │ ├── dashboards.yml │ └── side-car-dashboard.json │ └── datasources │ └── prometheus.yml ├── oracle ├── README.md ├── config │ ├── api.go │ ├── api_test.go │ ├── app.go │ ├── app_test.go │ ├── metrics.go │ ├── metrics_test.go │ ├── oracle.go │ ├── oracle_test.go │ ├── provider.go │ ├── provider_test.go │ ├── websocket.go │ └── websocket_test.go ├── constants │ └── chains.go ├── helpers_test.go ├── init.go ├── init_test.go ├── interfaces.go ├── lifecycle.go ├── lifecycle_test.go ├── market_mapper.go ├── market_mapper_test.go ├── metrics │ ├── dynamic_metrics.go │ ├── dynamic_metrics_test.go │ ├── metrics.go │ ├── mocks │ │ ├── mock_metrics.go │ │ └── mock_node_client.go │ ├── node.go │ └── node_test.go ├── metrics_test.go ├── mocks │ ├── PriceAggregator.go │ └── mock_oracle.go ├── options.go ├── oracle.go ├── oracle_test.go ├── providers_test.go ├── types │ ├── market.go │ ├── market_test.go │ ├── oracle.go │ └── provider.go ├── update.go └── update_test.go ├── pkg ├── arrays │ ├── arrays.go │ └── arrays_test.go ├── grpc │ ├── client.go │ └── client_test.go ├── http │ ├── address.go │ ├── round_tripper_with_headers.go │ └── round_tripper_with_headers_test.go ├── json │ ├── json.go │ └── json_test.go ├── log │ └── zap.go ├── math │ ├── bench_test.go │ ├── math.go │ ├── math_test.go │ ├── oracle │ │ ├── README.md │ │ ├── aggregator.go │ │ ├── aggregator_test.go │ │ ├── helper_test.go │ │ ├── utils.go │ │ └── utils_test.go │ ├── testutils.go │ ├── testutils │ │ └── median.go │ └── voteweighted │ │ ├── README.md │ │ ├── ccv_compat.go │ │ ├── interfaces.go │ │ ├── math_test.go │ │ ├── mocks │ │ ├── mock_cc_validator_store.go │ │ ├── mock_validator.go │ │ └── mock_validator_store.go │ │ └── voteweighted.go ├── slices │ ├── slices.go │ └── slices_test.go ├── sync │ └── sync.go └── types │ ├── currency_pair.go │ ├── currency_pair.pb.go │ └── currency_pair_test.go ├── proto ├── buf.gen.gogo.yaml ├── buf.gen.pulsar.yaml ├── buf.lock ├── buf.yaml └── connect │ ├── abci │ └── v2 │ │ └── vote_extensions.proto │ ├── marketmap │ ├── module │ │ └── v2 │ │ │ └── module.proto │ └── v2 │ │ ├── genesis.proto │ │ ├── market.proto │ │ ├── params.proto │ │ ├── query.proto │ │ └── tx.proto │ ├── oracle │ ├── module │ │ └── v2 │ │ │ └── module.proto │ └── v2 │ │ ├── genesis.proto │ │ ├── query.proto │ │ └── tx.proto │ ├── service │ └── v2 │ │ └── oracle.proto │ └── types │ └── v2 │ └── currency_pair.proto ├── providers ├── EXAMPLE.md ├── README.md ├── apis │ ├── README.md │ ├── binance │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── bitstamp │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── coinbase │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── coingecko │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── coinmarketcap │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── defi │ │ ├── ethmulticlient │ │ │ ├── client.go │ │ │ ├── mocks │ │ │ │ └── EVMClient.go │ │ │ ├── multi_client.go │ │ │ ├── multi_client_test.go │ │ │ └── utils.go │ │ ├── osmosis │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ ├── mocks │ │ │ │ └── client.go │ │ │ ├── price_fetcher.go │ │ │ ├── price_fetcher_test.go │ │ │ ├── types.go │ │ │ └── types_test.go │ │ ├── raydium │ │ │ ├── README.md │ │ │ ├── client.go │ │ │ ├── mocks │ │ │ │ └── solana_jsonrpc_client.go │ │ │ ├── multi_client.go │ │ │ ├── multi_client_test.go │ │ │ ├── price_fetcher.go │ │ │ ├── price_fetcher_test.go │ │ │ ├── schema │ │ │ │ └── amm_info.go │ │ │ └── types.go │ │ ├── types │ │ │ ├── block_age.go │ │ │ └── block_age_test.go │ │ └── uniswapv3 │ │ │ ├── README.md │ │ │ ├── fetcher.go │ │ │ ├── fetcher_test.go │ │ │ ├── helper_test.go │ │ │ ├── math.go │ │ │ ├── math_test.go │ │ │ ├── pool │ │ │ └── uniswap_v3_pool.go │ │ │ ├── utils.go │ │ │ └── utils_test.go │ ├── dydx │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ ├── helper_test.go │ │ ├── multi_market_map_fetcher.go │ │ ├── multi_market_map_fetcher_test.go │ │ ├── parse.go │ │ ├── parse_test.go │ │ ├── research_api_handler.go │ │ ├── research_api_handler_test.go │ │ ├── switch_over_fetcher.go │ │ ├── switch_over_fetcher_test.go │ │ ├── types │ │ │ ├── exchange_config_json.go │ │ │ ├── query.go │ │ │ └── research_json.go │ │ └── utils.go │ ├── geckoterminal │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── kraken │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── utils.go │ ├── marketmap │ │ ├── client.go │ │ ├── fetcher.go │ │ ├── fetcher_test.go │ │ └── utils.go │ └── polymarket │ │ ├── README.md │ │ ├── api_handler.go │ │ ├── api_handler_test.go │ │ └── config.go ├── architecture.png ├── base │ ├── api │ │ ├── errors │ │ │ └── api_query_handler.go │ │ ├── handlers │ │ │ ├── api_data_handler.go │ │ │ ├── api_query_handler.go │ │ │ ├── api_query_handler_test.go │ │ │ ├── mocks │ │ │ │ ├── api_data_handler.go │ │ │ │ ├── api_fetcher.go │ │ │ │ ├── api_query_handler.go │ │ │ │ ├── query_handler.go │ │ │ │ └── request_handler.go │ │ │ ├── options.go │ │ │ ├── request_handler.go │ │ │ └── rest_api_price_fetcher.go │ │ └── metrics │ │ │ ├── api_query_handler.go │ │ │ ├── constants.go │ │ │ └── mocks │ │ │ └── mock_metrics.go │ ├── config.go │ ├── config_test.go │ ├── fetch.go │ ├── metrics │ │ ├── mocks │ │ │ └── mock_metrics.go │ │ └── provider.go │ ├── options.go │ ├── provider.go │ ├── provider_test.go │ ├── testutils │ │ ├── http.go │ │ └── websocket.go │ ├── utils.go │ └── websocket │ │ ├── errors │ │ └── ws_query_handler.go │ │ ├── handlers │ │ ├── mocks │ │ │ ├── web_socket_conn_handler.go │ │ │ ├── web_socket_data_handler.go │ │ │ └── web_socket_query_handler.go │ │ ├── options.go │ │ ├── ws_conn_handler.go │ │ ├── ws_data_handler.go │ │ ├── ws_query_handler.go │ │ └── ws_query_handler_test.go │ │ └── metrics │ │ ├── mocks │ │ └── mock_metrics.go │ │ ├── ws_query_handler.go │ │ └── ws_utils.go ├── factories │ ├── README.md │ └── oracle │ │ ├── api.go │ │ ├── marketmap.go │ │ └── websocket.go ├── providertest │ ├── README.md │ ├── provider.go │ ├── util.go │ └── util_test.go ├── static │ ├── api_handler.go │ ├── client.go │ └── utils.go ├── types │ ├── errors.go │ ├── factory │ │ └── factory.go │ ├── mocks │ │ └── mock_provider.go │ ├── provider.go │ └── response.go ├── volatile │ ├── api_handler.go │ ├── api_handler_test.go │ ├── price.go │ └── price_test.go └── websockets │ ├── README.md │ ├── binance │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── bitfinex │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── bitstamp │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── bybit │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_hander_test.go │ └── ws_data_handler.go │ ├── coinbase │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── cryptodotcom │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── gate │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── huobi │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── kraken │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── kucoin │ ├── README.md │ ├── hooks.go │ ├── hooks_test.go │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ ├── mexc │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go │ └── okx │ ├── README.md │ ├── messages.go │ ├── parse.go │ ├── utils.go │ ├── ws_data_handler.go │ └── ws_data_handler_test.go ├── scripts ├── deprecated-exec.sh ├── genesis.go ├── genesis.sh ├── install.sh ├── protocgen-pulsar.sh └── protocgen.sh ├── service ├── clients │ ├── README.md │ ├── marketmap │ │ └── types │ │ │ └── types.go │ └── oracle │ │ ├── README.md │ │ ├── client.go │ │ ├── daemon.go │ │ ├── daemon_test.go │ │ ├── interface.go │ │ ├── mocks │ │ └── mock_oracle_client.go │ │ └── options.go ├── metrics │ ├── README.md │ ├── metrics.go │ ├── mocks │ │ └── mock_metrics.go │ └── types.go ├── servers │ ├── README.md │ ├── oracle │ │ ├── errors.go │ │ ├── helpers.go │ │ ├── interface.go │ │ ├── mocks │ │ │ └── mock_oracle_service.go │ │ ├── server.go │ │ ├── server_test.go │ │ └── types │ │ │ ├── oracle.pb.go │ │ │ └── oracle.pb.gw.go │ └── prometheus │ │ ├── server.go │ │ └── server_test.go └── validation │ ├── validation.go │ └── validation_test.go ├── tests ├── integration │ ├── connect_ccv_suite.go │ ├── connect_integration_test.go │ ├── connect_setup.go │ ├── connect_suite.go │ ├── connect_validator_suite.go │ ├── consumer.go │ ├── go.mod │ └── go.sum ├── petri │ ├── chain_config.go │ ├── connect_suite.go │ ├── connect_test.go │ ├── go.mod │ └── go.sum └── simapp │ ├── ante.go │ ├── app.go │ ├── config.go │ ├── connectd │ ├── main.go │ └── testappd │ │ ├── cmd_test.go │ │ ├── root.go │ │ └── testnet.go │ ├── export.go │ ├── go.mod │ ├── go.sum │ ├── helpers.go │ └── params │ └── encoding.go ├── testutil └── testutil.go ├── tools └── tools.go └── x ├── marketmap ├── README.md ├── client │ └── cli │ │ └── query.go ├── keeper │ ├── genesis.go │ ├── genesis_test.go │ ├── hooks.go │ ├── keeper.go │ ├── keeper_test.go │ ├── msg_server.go │ ├── msg_server_test.go │ ├── options.go │ ├── query.go │ ├── query_test.go │ └── validation_hooks.go ├── module.go └── types │ ├── client.go │ ├── codec.go │ ├── errors.go │ ├── events.go │ ├── genesis.go │ ├── genesis.pb.go │ ├── genesis_test.go │ ├── hooks.go │ ├── keys.go │ ├── market.go │ ├── market.pb.go │ ├── market_test.go │ ├── mocks │ ├── MarketMapHooks.go │ └── QueryClient.go │ ├── msg.go │ ├── msg_test.go │ ├── params.go │ ├── params.pb.go │ ├── params_test.go │ ├── provider.go │ ├── provider_test.go │ ├── query.pb.go │ ├── query.pb.gw.go │ ├── ticker.go │ ├── ticker_test.go │ ├── tickermetadata │ ├── common.go │ ├── common_test.go │ ├── core.go │ ├── core_test.go │ ├── dydx.go │ └── dydx_test.go │ ├── tx.pb.go │ ├── utils.go │ ├── validation_hooks.go │ └── validation_hooks_test.go └── oracle ├── client └── cli │ └── query.go ├── keeper ├── abci.go ├── abci_test.go ├── genesis.go ├── genesis_test.go ├── grpc_query.go ├── grpc_query_test.go ├── hooks.go ├── keeper.go ├── keeper_test.go ├── msg_server.go └── msg_server_test.go ├── module.go └── types ├── codec.go ├── currency_pair_state.go ├── currency_pair_state_test.go ├── errors.go ├── expected_keepers.go ├── genesis.go ├── genesis.pb.go ├── genesis_test.go ├── keys.go ├── mocks └── market_map_keeper.go ├── msgs.go ├── msgs_test.go ├── query.pb.go ├── query.pb.gw.go ├── quote_price.go ├── quote_price_test.go └── tx.pb.go /.dockerignore: -------------------------------------------------------------------------------- 1 | go.work* 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS: https://help.github.com/articles/about-codeowners/ 2 | 3 | # NOTE: Order is important; the last matching pattern takes the most precedence 4 | 5 | # Primary repo maintainers 6 | 7 | * @skip-mev/skip-connect 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: "/" 8 | schedule: 9 | interval: daily 10 | 11 | - package-ecosystem: gomod 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | labels: 16 | - "A:automerge" 17 | - dependencies -------------------------------------------------------------------------------- /.github/workflows/buf_breaking.yml: -------------------------------------------------------------------------------- 1 | name: Proto Breaking 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - proto/** 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: bufbuild/buf-setup-action@v1 15 | with: 16 | version: "1.39.0" 17 | # Run breaking change detection for Protobuf sources against the current 18 | # `main` branch, 'proto' subdirectory 19 | - uses: bufbuild/buf-breaking-action@v1 20 | with: 21 | input: proto 22 | against: https://github.com/skip-mev/connect.git#branch=main,ref=HEAD~1,subdir=proto 23 | -------------------------------------------------------------------------------- /.github/workflows/buf_push.yml: -------------------------------------------------------------------------------- 1 | name: BSR Push 2 | 3 | on: 4 | # Apply to pushes on 'main' branch that affect the 'proto' directory 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - proto/** 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: bufbuild/buf-setup-action@v1 16 | with: 17 | version: "1.39.0" 18 | - uses: bufbuild/buf-push-action@v1 19 | with: 20 | input: proto 21 | buf_token: ${{ secrets.BUF_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Multi-Arch Build for Go 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | multi-arch-build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | os: [linux, darwin] 16 | arch: [amd64, arm64, 386] 17 | exclude: 18 | - os: darwin 19 | arch: 386 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: "1.23.3" 29 | 30 | - name: Set environment variables 31 | run: | 32 | echo "GOOS=${{ matrix.os }}" >> $GITHUB_ENV 33 | echo "GOARCH=${{ matrix.arch }}" >> $GITHUB_ENV 34 | 35 | - name: Build chain and sidecar 36 | run: | 37 | make build-test-app && make build 38 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E tests 2 | on: 3 | pull_request: 4 | paths-ignore: 5 | - docs/** 6 | branches: 7 | - main 8 | workflow_dispatch: 9 | 10 | jobs: 11 | e2e: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: 1.23.0 18 | cache: true 19 | cache-dependency-path: go.sum 20 | - uses: technote-space/get-diff-action@v6.1.2 21 | id: git_diff 22 | with: 23 | PATTERNS: | 24 | **/*.go 25 | go.mod 26 | go.sum 27 | - name: tests 28 | if: env.GIT_DIFF 29 | run: | 30 | make test-integration 31 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | permissions: 16 | pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs 17 | statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@v5.5.3 21 | id: lint_pr_title 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - uses: marocchino/sticky-pull-request-comment@v2 26 | # When the previous steps fails, the workflow would stop. By adding this 27 | # condition you can continue the execution with the populated error message. 28 | if: always() && (steps.lint_pr_title.outputs.error_message != null) 29 | with: 30 | header: pr-title-lint-error 31 | message: | 32 | Hey there and thank you for opening this pull request! 👋🏼 33 | 34 | We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. 35 | 36 | Details: 37 | 38 | ``` 39 | ${{ steps.lint_pr_title.outputs.error_message }} 40 | ``` 41 | 42 | # Delete a previous comment when the issue has been resolved 43 | - if: ${{ steps.lint_pr_title.outputs.error_message == null }} 44 | uses: marocchino/sticky-pull-request-comment@v2 45 | with: 46 | header: pr-title-lint-error 47 | delete: true 48 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - release/** 7 | pull_request: 8 | merge_group: 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | # pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: golangci-lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version: 1.23.0 22 | - uses: actions/checkout@v4 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v6 25 | with: 26 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 27 | version: latest 28 | only-new-issues: true 29 | lint-markdown: 30 | name: Lint markdown 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Check out code 34 | uses: actions/checkout@v4 35 | 36 | - name: Lint markdown 37 | uses: avto-dev/markdown-lint@v1 38 | with: 39 | args: "**/*.md" 40 | govulncheck: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-go@v5 45 | with: 46 | go-version: 1.23.0 47 | cache: true 48 | cache-dependency-path: go.sum 49 | - uses: technote-space/get-diff-action@v6.1.2 50 | id: git_diff 51 | with: 52 | PATTERNS: | 53 | **/*.go 54 | go.mod 55 | go.sum 56 | - name: govulncheck 57 | if: env.GIT_DIFF 58 | run: | 59 | make govulncheck 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | # This workflow helps with creating releases. 3 | # This job will only be triggered when a tag (vX.X.x) is pushed 4 | on: 5 | push: 6 | # Sequence of patterns matched against refs/tags 7 | tags: 8 | - "v*.**" # Push events to matching v*.*, i.e. v0.0.0, v0.0.0-alpha.0, etc. 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | release: 15 | permissions: 16 | contents: write # for goreleaser/goreleaser-action to create a GitHub release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: 1.23.0 24 | - name: Unshallow 25 | run: git fetch --prune --unshallow 26 | - name: Create release 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | args: release --clean 30 | version: "~> v1" 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests / Code Coverage 2 | on: 3 | pull_request: 4 | merge_group: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - tests/** 10 | - docs/** 11 | 12 | permissions: 13 | contents: read 14 | 15 | concurrency: 16 | group: ci-${{ github.ref }}-tests 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-go@v5 25 | with: 26 | go-version: 1.23.0 27 | cache: true 28 | cache-dependency-path: go.sum 29 | - uses: technote-space/get-diff-action@v6.1.2 30 | id: git_diff 31 | with: 32 | PATTERNS: | 33 | **/*.go 34 | go.mod 35 | go.sum 36 | - name: tests 37 | if: env.GIT_DIFF 38 | run: | 39 | make test 40 | - name: test-cover 41 | run: | 42 | make test-cover 43 | - name: Upload coverage reports to Codecov 44 | uses: codecov/codecov-action@v5 45 | env: 46 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 47 | with: 48 | files: cover.out 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | *.bak 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work* 23 | 24 | # Testing script folders 25 | /tests/.connectd 26 | 27 | # build dir 28 | /build/ 29 | 30 | run.go 31 | 32 | /tests/e2e/.connect-e2e-testnet-* 33 | 34 | # ide 35 | .idea/* 36 | .vscode/* 37 | **/.DS_Store 38 | 39 | *.log* 40 | oracle.json 41 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | project_name: connect 3 | 4 | release: 5 | github: 6 | owner: skip-mev 7 | name: connect 8 | prerelease: true 9 | 10 | builds: 11 | - main: 'cmd/connect/main.go' 12 | goos: 13 | - 'linux' 14 | - 'darwin' 15 | binary: 'connect' 16 | id: 'connect' 17 | ldflags: 18 | - "-X github.com/skip-mev/connect/v2/cmd/build.Build={{.Version}}" 19 | archives: 20 | - format: tar.gz 21 | wrap_in_directory: true 22 | format_overrides: 23 | - goos: windows 24 | format: zip 25 | name_template: "{{ .Binary }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 26 | files: 27 | - README.md 28 | 29 | snapshot: 30 | name_template: SNAPSHOT-{{ .Commit }} 31 | 32 | changelog: 33 | skip: false 34 | use: 'github' 35 | 36 | checksum: 37 | disable: false 38 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD004": { "style": "asterisk" }, 4 | "MD007": { "indent": 4 }, 5 | "MD024": { "siblings_only": true }, 6 | "MD025": false, 7 | "MD033": false, 8 | "MD034": false, 9 | "MD014": false, 10 | "MD013": false, 11 | "no-hard-tabs": false, 12 | "whitespace": false 13 | } -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD004": { 4 | "style": "asterisk" 5 | }, 6 | "MD007": { 7 | "indent": 4 8 | }, 9 | "MD013": false, 10 | "MD024": { 11 | "siblings_only": true 12 | }, 13 | "MD025": false, 14 | "MD033": false, 15 | "MD034": false, 16 | "no-hard-tabs": false, 17 | "whitespace": false 18 | } -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: default 3 | conditions: 4 | - "#approved-reviews-by>1" 5 | 6 | pull_request_rules: 7 | - name: automerge to main with label automerge and branch protection passing 8 | conditions: 9 | - "#approved-reviews-by>1" 10 | - base=main 11 | - label=A:automerge 12 | actions: 13 | queue: 14 | name: default 15 | method: squash 16 | commit_message_template: | 17 | {{ title }} (#{{ number }}) 18 | {{ body }} 19 | - name: backport patches to v0.x.x branch 20 | conditions: 21 | - base=main 22 | - label=backport/v0.x.x 23 | actions: 24 | backport: 25 | branches: 26 | - release/v0.x.x 27 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | with-expecter: True -------------------------------------------------------------------------------- /abci/preblock/oracle/README.md: -------------------------------------------------------------------------------- 1 | # Oracle PreBlock Handler 2 | 3 | ## Overview 4 | 5 | Writing vote extensions to state is possible using the SDK's `PreBlocker` - which allows you to modify the state before the block is executed 6 | and committed. Since the vote extensions not directly accessible from the `PreBlocker`, we inject the vote extensions in `PrepareProposal` and verify them in `ProcessProposal` before a block is accepted by the network. 7 | 8 | The `PreBlockHandler` assumes that the vote extensions are already verified by validators in the network and are ready to be aggregated. A bad vote extension included in a proposal implies that the 9 | network has accepted a bad proposal. 10 | 11 | ## Usage 12 | 13 | To use the preblock handler, you need to initialize the preblock handler in your `app.go` file. By default, we encourage users to use the aggregation function defined in `abci/preblock/math` to aggregate the votes. This will aggregate all prices and calculate a stake-weighted median for each supported asset. 14 | 15 | The `PreBlockHandler` currently only supports assets that are initialized in the oracle keeper. However, allowing any type of asset can be supported with a small modification to `WritePrices` (TBD whether we will support this). 16 | -------------------------------------------------------------------------------- /abci/preblock/utils.go: -------------------------------------------------------------------------------- 1 | package preblock 2 | 3 | import ( 4 | cometabci "github.com/cometbft/cometbft/abci/types" 5 | sdk "github.com/cosmos/cosmos-sdk/types" 6 | ) 7 | 8 | // NoOpPreBlocker is a no-op preblocker. This should only be used for testing. 9 | func NoOpPreBlocker() sdk.PreBlocker { 10 | return func(_ sdk.Context, _ *cometabci.RequestFinalizeBlock) (*sdk.ResponsePreBlock, error) { 11 | return &sdk.ResponsePreBlock{}, nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /abci/proposals/errors.go: -------------------------------------------------------------------------------- 1 | package proposals 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // InvalidExtendedCommitInfoError is an error that is returned when a proposed ExtendedCommitInfo is invalid. 8 | type InvalidExtendedCommitInfoError struct { 9 | Err error 10 | } 11 | 12 | func (e InvalidExtendedCommitInfoError) Error() string { 13 | return fmt.Sprintf("invalid extended commit info: %s", e.Err.Error()) 14 | } 15 | 16 | func (e InvalidExtendedCommitInfoError) Label() string { 17 | return "InvalidExtendedCommitInfoError" 18 | } 19 | -------------------------------------------------------------------------------- /abci/proposals/options.go: -------------------------------------------------------------------------------- 1 | package proposals 2 | 3 | // Option is a function that enables optional configuration of the ProposalHandler. 4 | type Option func(*ProposalHandler) 5 | 6 | // RetainOracleDataInWrappedProposalHandler returns an Option that configures the 7 | // ProposalHandler to pass the injected extend-commit-info to the wrapped proposal handler. 8 | func RetainOracleDataInWrappedProposalHandler() Option { 9 | return func(p *ProposalHandler) { 10 | p.retainOracleDataInWrappedHandler = true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /abci/strategies/aggregator/errors.go: -------------------------------------------------------------------------------- 1 | package aggregator 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // CommitPricesError is an error that is returned when there is a failure in committing the prices to state. 8 | type CommitPricesError struct { 9 | Err error 10 | } 11 | 12 | func (e CommitPricesError) Error() string { 13 | return fmt.Sprintf("commit prices error: %s", e.Err.Error()) 14 | } 15 | 16 | func (e CommitPricesError) Label() string { 17 | return "CommitPricesError" 18 | } 19 | 20 | // PriceAggregationError is an error that is returned when there is a failure in aggregating the prices. 21 | type PriceAggregationError struct { 22 | Err error 23 | } 24 | 25 | func (e PriceAggregationError) Error() string { 26 | return fmt.Sprintf("price aggregation error: %s", e.Err.Error()) 27 | } 28 | 29 | func (e PriceAggregationError) Label() string { 30 | return "PriceAggregationError" 31 | } 32 | -------------------------------------------------------------------------------- /abci/types/constants.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | // MaximumPriceSize defines the maximum size of a price in bytes. This allows 5 | // up to 32 bytes for the price and 1 byte for the sign (positive/negative). 6 | MaximumPriceSize = 33 7 | 8 | // NumInjectedTxs is the number of transactions that were injected into 9 | // the proposal but are not actual transactions. In this case, the oracle 10 | // info is injected into the proposal but should be ignored by the application. 11 | NumInjectedTxs = 1 12 | 13 | // OracleInfoIndex is the index of the oracle info in the proposal. 14 | OracleInfoIndex = 0 15 | ) 16 | -------------------------------------------------------------------------------- /abci/types/errors.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | 6 | servicemetrics "github.com/skip-mev/connect/v2/service/metrics" 7 | ) 8 | 9 | // NilRequestError is an error that is returned when a nil request is given to the handler. 10 | type NilRequestError struct { 11 | Handler servicemetrics.ABCIMethod 12 | } 13 | 14 | func (e NilRequestError) Error() string { 15 | return fmt.Sprintf("nil request for %s", e.Handler) 16 | } 17 | 18 | func (e NilRequestError) Label() string { 19 | return "NilRequestError" 20 | } 21 | 22 | // WrappedHandlerError is an error that is returned when a handler that is wrapped by a Connect ABCI handler 23 | // returns an error. 24 | type WrappedHandlerError struct { 25 | Handler servicemetrics.ABCIMethod 26 | Err error 27 | } 28 | 29 | func (e WrappedHandlerError) Error() string { 30 | return fmt.Sprintf("wrapped %s failed: %s", e.Handler, e.Err.Error()) 31 | } 32 | 33 | func (e WrappedHandlerError) Label() string { 34 | return "WrappedHandlerError" 35 | } 36 | 37 | // CodecError is an error that is returned when a codec fails to marshal or unmarshal a type. 38 | type CodecError struct { 39 | Err error 40 | } 41 | 42 | func (e CodecError) Error() string { 43 | return fmt.Sprintf("codec error: %s", e.Err.Error()) 44 | } 45 | 46 | func (e CodecError) Label() string { 47 | return "CodecError" 48 | } 49 | 50 | // MissingCommitInfoError is an error that is returned when a proposal is missing the CommitInfo from the previous 51 | // height. 52 | type MissingCommitInfoError struct{} 53 | 54 | func (e MissingCommitInfoError) Error() string { 55 | return "missing commit info" 56 | } 57 | 58 | func (e MissingCommitInfoError) Label() string { 59 | return "MissingCommitInfoError" 60 | } 61 | -------------------------------------------------------------------------------- /abci/types/interfaces.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc" 7 | 8 | connecttypes "github.com/skip-mev/connect/v2/pkg/types" 9 | servertypes "github.com/skip-mev/connect/v2/service/servers/oracle/types" 10 | oracletypes "github.com/skip-mev/connect/v2/x/oracle/types" 11 | ) 12 | 13 | // OracleKeeper defines the interface that must be fulfilled by the oracle keeper. This 14 | // interface is utilized by the PreBlock handler to write oracle data to state for the 15 | // supported assets. 16 | // 17 | //go:generate mockery --name OracleKeeper --filename mock_oracle_keeper.go 18 | type OracleKeeper interface { //golint:ignore 19 | GetAllCurrencyPairs(ctx context.Context) []connecttypes.CurrencyPair 20 | SetPriceForCurrencyPair(ctx context.Context, cp connecttypes.CurrencyPair, qp oracletypes.QuotePrice) error 21 | } 22 | 23 | // OracleClient defines the interface that must be fulfilled by the connect client. 24 | // This interface is utilized by the vote extension handler to fetch prices. 25 | type OracleClient interface { 26 | Prices(ctx context.Context, in *servertypes.QueryPricesRequest, opts ...grpc.CallOption) (*servertypes.QueryPricesResponse, error) 27 | } 28 | -------------------------------------------------------------------------------- /abci/types/utils.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | servicemetrics "github.com/skip-mev/connect/v2/service/metrics" 8 | ) 9 | 10 | // RecordLatencyAndStatus is used by the ABCI handlers to record their e2e latency, and the status of the request 11 | // to their corresponding metrics objects. 12 | func RecordLatencyAndStatus( 13 | metrics servicemetrics.Metrics, latency time.Duration, err error, method servicemetrics.ABCIMethod, 14 | ) { 15 | // observe latency 16 | metrics.ObserveABCIMethodLatency(method, latency) 17 | 18 | // increment the number of extend vote requests 19 | var label servicemetrics.Labeller 20 | if err != nil { 21 | _ = errors.As(err, &label) 22 | } else { 23 | label = servicemetrics.Success{} 24 | } 25 | metrics.AddABCIRequest(method, label) 26 | } 27 | -------------------------------------------------------------------------------- /abci/ve/errors.go: -------------------------------------------------------------------------------- 1 | package ve 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // PreBlockError is an error that is returned when the pre-block simulation fails. 8 | type PreBlockError struct { 9 | Err error 10 | } 11 | 12 | func (e PreBlockError) Error() string { 13 | return fmt.Sprintf("finalize block error: %s", e.Err.Error()) 14 | } 15 | 16 | func (e PreBlockError) Label() string { 17 | return "PreBlockError" 18 | } 19 | 20 | // ErrPanic is an error that is returned when a panic occurs in the ABCI handler. 21 | type ErrPanic struct { 22 | Err error 23 | } 24 | 25 | func (e ErrPanic) Error() string { 26 | return fmt.Sprintf("panic: %s", e.Err.Error()) 27 | } 28 | 29 | func (e ErrPanic) Label() string { 30 | return "Panic" 31 | } 32 | 33 | // OracleClientError is an error that is returned when the oracle client's response is invalid. 34 | type OracleClientError struct { 35 | Err error 36 | } 37 | 38 | func (e OracleClientError) Error() string { 39 | return fmt.Sprintf("oracle client error: %s", e.Err.Error()) 40 | } 41 | 42 | func (e OracleClientError) Label() string { 43 | return "OracleClientError" 44 | } 45 | 46 | // TransformPricesError is an error that is returned when there is a failure in attempting to transform the prices returned 47 | // from the oracle server to the format expected by the validator set. 48 | type TransformPricesError struct { 49 | Err error 50 | } 51 | 52 | func (e TransformPricesError) Error() string { 53 | return fmt.Sprintf("prices transform error: %s", e.Err.Error()) 54 | } 55 | 56 | func (e TransformPricesError) Label() string { 57 | return "TransformPricesError" 58 | } 59 | 60 | // ValidateVoteExtensionError is an error that is returned when there is a failure in validating a vote extension. 61 | type ValidateVoteExtensionError struct { 62 | Err error 63 | } 64 | 65 | func (e ValidateVoteExtensionError) Error() string { 66 | return fmt.Sprintf("validate vote extension error: %s", e.Err.Error()) 67 | } 68 | 69 | func (e ValidateVoteExtensionError) Label() string { 70 | return "ValidateVoteExtensionError" 71 | } 72 | -------------------------------------------------------------------------------- /aggregator/options.go: -------------------------------------------------------------------------------- 1 | package aggregator 2 | 3 | // DataAggregatorOption is a function that is used to parametrize a DataAggregator 4 | // instance. 5 | type DataAggregatorOption[K comparable, V any] func(*DataAggregator[K, V]) 6 | 7 | // WithAggregateFn sets the aggregateFn of a DataAggregatorOptions. 8 | func WithAggregateFn[K comparable, V any](fn AggregateFn[K, V]) DataAggregatorOption[K, V] { 9 | return func(opts *DataAggregator[K, V]) { 10 | opts.aggregateFn = fn 11 | } 12 | } 13 | 14 | // WithAggregateFnFromContext sets the aggregateFnFromContext of a DataAggregatorOptions. 15 | func WithAggregateFnFromContext[K comparable, V any](fn AggregateFnFromContext[K, V]) DataAggregatorOption[K, V] { 16 | return func(opts *DataAggregator[K, V]) { 17 | opts.aggregateFnFromContext = fn 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cmd/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | var Build string 4 | -------------------------------------------------------------------------------- /cmd/client/README.md: -------------------------------------------------------------------------------- 1 | # Oracle Client 2 | 3 | ## Overview 4 | 5 | The oracle client can be run concurrently with the oracle server. It is meant to be a useful tool for interacting with the oracle server and ensuring the price feed is working as expected. 6 | 7 | ## Usage 8 | 9 | The oracle client can be run with the following command: 10 | 11 | ```bash 12 | make run-oracle-client 13 | ``` 14 | 15 | However, before running the oracle client, you must first run the oracle server. To start the oracle server run the following command: 16 | 17 | ```bash 18 | make run-oracle-server 19 | ``` 20 | 21 | One additionally useful tool is to run the prometheus server and check the metrics that are being collected. To start the prometheus server run the following command: 22 | 23 | ```bash 24 | make run-prom-client 25 | ``` 26 | 27 | After starting the prometheus server, you can view the metrics by navigating to `localhost:9090` in your browser. 28 | -------------------------------------------------------------------------------- /cmd/connect/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestCheckMarketMapEndpoint(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | endpoint string 13 | wantErr bool 14 | errMsg string 15 | }{ 16 | { 17 | name: "Valid gRPC endpoint", 18 | endpoint: "example.com:8080", 19 | wantErr: false, 20 | }, 21 | { 22 | name: "Valid IP address endpoint", 23 | endpoint: "192.168.1.1:9090", 24 | wantErr: false, 25 | }, 26 | { 27 | name: "HTTP endpoint", 28 | endpoint: "http://example.com:8080", 29 | wantErr: true, 30 | errMsg: `expected gRPC endpoint but got HTTP endpoint "http://example.com:8080". Please provide a gRPC endpoint (e.g. some.host:9090)`, 31 | }, 32 | { 33 | name: "HTTPS endpoint", 34 | endpoint: "https://example.com:8080", 35 | wantErr: true, 36 | errMsg: `expected gRPC endpoint but got HTTP endpoint "https://example.com:8080". Please provide a gRPC endpoint (e.g. some.host:9090)`, 37 | }, 38 | { 39 | name: "Missing port", 40 | endpoint: "example.com", 41 | wantErr: true, 42 | errMsg: `invalid gRPC endpoint "example.com". Must specify port (e.g. example.com:9090)`, 43 | }, 44 | { 45 | name: "Invalid port format", 46 | endpoint: "example.com:port", 47 | wantErr: true, 48 | errMsg: `invalid gRPC endpoint "example.com:port". Must specify port (e.g. example.com:9090)`, 49 | }, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | err := isValidGRPCEndpoint(tt.endpoint) 55 | if tt.wantErr { 56 | require.EqualError(t, err, tt.errMsg) 57 | } else { 58 | require.NoError(t, err) 59 | } 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cmd/constants/marketmaps/markets_test.go: -------------------------------------------------------------------------------- 1 | package marketmaps_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/cmd/constants/marketmaps" 9 | marketmaptypes "github.com/skip-mev/connect/v2/x/marketmap/types" 10 | ) 11 | 12 | func TestMarkets(t *testing.T) { 13 | markets := []map[string]marketmaptypes.Market{ 14 | marketmaps.RaydiumMarketMap.Markets, 15 | marketmaps.CoreMarketMap.Markets, 16 | marketmaps.UniswapV3BaseMarketMap.Markets, 17 | marketmaps.CoinGeckoMarketMap.Markets, 18 | marketmaps.OsmosisMarketMap.Markets, 19 | marketmaps.PolymarketMarketMap.Markets, 20 | marketmaps.ForexMarketMap.Markets, 21 | } 22 | for _, m := range markets { 23 | require.NotEmpty(t, m) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - ".github/**/*" 3 | - "**/*.sh" 4 | - "**/*.mdx" 5 | - "**/*.pulsar.go" 6 | - "**/*.pb.go" 7 | - "**/*.json" 8 | -------------------------------------------------------------------------------- /contrib/images/connect.base.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-bullseye AS builder 2 | 3 | RUN curl -sSLf "$(curl -sSLf https://api.github.com/repos/tomwright/dasel/releases/latest | grep browser_download_url | grep linux_amd64 | grep -v .gz | cut -d\" -f 4)" -L -o dasel && chmod +x dasel && mv ./dasel /usr/local/bin/dasel 4 | 5 | RUN apt-get update && apt-get install jq -y && apt-get install ca-certificates -y 6 | -------------------------------------------------------------------------------- /contrib/images/connect.e2e.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/skip-mev/connect-dev-base AS builder 2 | 3 | WORKDIR /src/connect 4 | 5 | COPY go.mod . 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN make build-test-app 12 | 13 | ## Prepare the final clear binary 14 | ## This will expose the tendermint and cosmos ports alongside 15 | ## starting up the sim app and the connect daemon 16 | FROM ubuntu:rolling 17 | EXPOSE 26656 26657 1317 9090 7171 26655 8081 26660 18 | 19 | RUN apt-get update && apt-get install jq -y && apt-get install ca-certificates -y 20 | ENTRYPOINT ["connectd", "start"] 21 | 22 | COPY --from=builder /src/connect/build/* /usr/local/bin/ 23 | -------------------------------------------------------------------------------- /contrib/images/connect.generator.dev.Dockerfile: -------------------------------------------------------------------------------- 1 | # ./contrib/images/connect.generator.dev.Dockerfile 2 | 3 | # Stage 1: Build the Go application 4 | FROM golang:1.23 AS builder 5 | 6 | WORKDIR /src/connect 7 | 8 | COPY go.mod . 9 | 10 | RUN go mod download 11 | 12 | COPY . . 13 | 14 | RUN make build 15 | 16 | # Stage 2: Create a lightweight image for running the application 17 | FROM ubuntu:rolling 18 | COPY --from=builder /src/connect/build/* /usr/local/bin/ 19 | 20 | # Create the /data directory 21 | RUN mkdir -p /data 22 | # Define the volume where the generated file will be stored 23 | VOLUME /data 24 | 25 | # The entrypoint will be provided by the docker-compose file 26 | ENTRYPOINT ["/usr/local/bin/scripts"] 27 | -------------------------------------------------------------------------------- /contrib/images/connect.local.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/skip-mev/connect-dev-base AS builder 2 | 3 | WORKDIR /src/connect 4 | 5 | COPY go.mod . 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN make build-test-app 12 | 13 | ## Prepare the final clear binary 14 | ## This will expose the tendermint and cosmos ports alongside 15 | ## starting up the sim app and the connect daemon 16 | EXPOSE 26656 26657 1317 9090 7171 26655 8081 26660 17 | RUN apt-get update && apt-get install jq -y && apt-get install ca-certificates -y 18 | ENTRYPOINT ["make", "build-and-start-app"] 19 | 20 | -------------------------------------------------------------------------------- /contrib/images/connect.sidecar.dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/skip-mev/connect-dev-base AS builder 2 | 3 | WORKDIR /src/connect 4 | 5 | COPY go.mod . 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN make build 12 | 13 | FROM ubuntu:rolling 14 | EXPOSE 8080 8002 15 | 16 | COPY --from=builder /src/connect/build/* /usr/local/bin/ 17 | RUN apt-get update && apt-get install jq -y && apt-get install ca-certificates -y 18 | 19 | WORKDIR /usr/local/bin/ 20 | ENTRYPOINT [ "connect" ] 21 | -------------------------------------------------------------------------------- /contrib/images/connect.sidecar.prod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/skip-mev/connect-dev-base AS builder 2 | 3 | WORKDIR /src/connect 4 | 5 | COPY go.mod . 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN make build 12 | 13 | FROM gcr.io/distroless/base-debian11:debug 14 | EXPOSE 8080 8002 15 | 16 | COPY --from=builder /src/connect/build/* /usr/local/bin/ 17 | 18 | WORKDIR /usr/local/bin/ 19 | CMD [ "connect" ] 20 | -------------------------------------------------------------------------------- /contrib/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | # my global config 2 | global: 3 | scrape_interval: 100ms # Adjust this as necessary 4 | evaluation_interval: 1s 5 | scrape_configs: 6 | - job_name: "prometheus" 7 | static_configs: 8 | - targets: ["oracle:8002", "blockchain:26660"] -------------------------------------------------------------------------------- /docs/developers/providers.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Providers 3 | icon: book-sparkles 4 | --- 5 | 6 | Connect pulls data from `Providers`. `Providers` utilize public REST APIs, RPCs, and websockets from either centralized or decentralized exchanges. Below is a list of all available `Providers` in Connect. 7 | 8 | # Centralized Exchange Providers 9 | 10 | ### REST API 11 | 12 | - binance_api 13 | - bitstamp_api 14 | - coinbase_api 15 | - kraken_api 16 | - polymarket_api 17 | 18 | 19 | ### Websocket 20 | 21 | - binance_ws 22 | - bitfinex_ws 23 | - bitstamp_ws 24 | - bybit_ws 25 | - coinbase_ws 26 | - crypto_dot_com_ws 27 | - gate_ws 28 | - huobi_ws 29 | - kraken_ws 30 | - kucoin_ws 31 | - mexc_ws 32 | - okx_ws 33 | 34 | # Decentralized Exchange Providers 35 | 36 | ### REST API 37 | 38 | - uniswapv3_api-ethereum 39 | - uniswapv3_api-base 40 | - raydium_api 41 | - osmosis_api 42 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/favicon.ico -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/favicon.png -------------------------------------------------------------------------------- /docs/img/connect-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/connect-arch.png -------------------------------------------------------------------------------- /docs/img/connect-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/connect-banner.png -------------------------------------------------------------------------------- /docs/img/connect-customers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/connect-customers.png -------------------------------------------------------------------------------- /docs/img/connect-town-crier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/connect-town-crier.png -------------------------------------------------------------------------------- /docs/img/docs/validator/quotes-correct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/docs/validator/quotes-correct.png -------------------------------------------------------------------------------- /docs/img/docs/validator/quotes-wrong.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/docs/validator/quotes-wrong.png -------------------------------------------------------------------------------- /docs/img/searcher/allowed_bundles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/searcher/allowed_bundles.png -------------------------------------------------------------------------------- /docs/img/searcher/disallowed_bundles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/searcher/disallowed_bundles.png -------------------------------------------------------------------------------- /docs/img/skip-og-image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/skip-og-image.jpeg -------------------------------------------------------------------------------- /docs/img/slinky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/slinky.png -------------------------------------------------------------------------------- /docs/img/slinky_math.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/docs/img/slinky_math.jpeg -------------------------------------------------------------------------------- /docs/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | icon: crystal-ball 4 | --- 5 | 6 | ## What is Connect? 7 | 8 | **Connect** is an oracle that securely delivers offchain data to onchain applications using a chain's native validator set and security. This documentation will guide you through using Connect; whether you're a **validator** or **developer**. 9 | 10 | 11 | 12 | Learn how to setup and run Connect with your infrastructure. 13 | 14 | 15 | Learn how to use the Connect API to access data. 16 | 17 | 18 | 19 | ### Features 20 | 21 | #### Security 22 | 23 | Connect leverages the chain’s security, giving the fastest updates possible, and removing the requirements for any 3rd party systems. 24 | 25 | #### Performance 26 | 27 | Connect can support over 2000 currency pairs and price feeds, allowing the launch of thousands of permissionless on-chain markets. Ultimately, the speed of Connect is determined by the speed of your application. 28 | 29 | #### Support 30 | 31 | Connect comes with a 1-day SLAs for adding new feeds, and 24/7 on-call support and maintenance by the Skip team. 32 | 33 | #### UX 34 | 35 | By leveraging new advancements in consensus like vote extensions & ABCI++, Connect guarantees a millisecond-fresh oracle update every block, allowing applications to build without sacrificing UX for safety. 36 | 37 | 38 | ## Ready to Connect? 39 | 40 | If you're interested in using Connect, please check out our [website](https://skip.build) and reach out to us on [Discord](https://discord.gg/PeBGE9jrbu) to get started. -------------------------------------------------------------------------------- /docs/logo/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/logo/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/metrics/application-reference.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | description: Reference for the available metrics in the Application 3 | title: Application 4 | icon: display-code 5 | --- 6 | 7 | ## Exposed Metrics 8 | 9 | By default, Cosmos SDK application expose metrics on port `26660`. These metrics are in the [Prometheus](https://prometheus.io/docs/introduction/overview/) format and can be scraped by any monitoring system that supports Prometheus format. 10 | 11 | ## Oracle Client Connection Metrics 12 | 13 | - **oracle_response_latency:** Histogram that measures the time in nanoseconds between request/response of from the Application to Oracle. 14 | - **oracle_responses:** Counter that measures the number of oracle responses. 15 | 16 | ## ABCI Metrics 17 | 18 | The following metrics are measured in the following ABCI methods: ExtendVote, PrepareProposal, ProcessProposal, VerifyVoteExtension, FinalizeBlock. 19 | 20 | - **ABCI_method_latency:** Histogram that measures the amount of seconds ABCI method calls take. 21 | - **ABCI_method_status:** Counter that measures the number of ABCI requests. 22 | 23 | ## VE/Price Metrics 24 | 25 | - **message_size:** Histogram that tracks the size of vote-extensions and extended commits that Connect transmits. 26 | - **oracle_prices:** Gauge that tracks prices written to application state. 27 | 28 | ## Network Metrics 29 | 30 | - **oracle_reports_per_validator:** Gauge that tracks the prices each validator has reported for any block per ticker. 31 | - **oracle_report_status_per_validator:** Counter that tracks the number of reports per validator and their vote status (absent, missing_price, with_price). -------------------------------------------------------------------------------- /docs/metrics/overview.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | description: Overview of metrics in Connect 3 | title: Overview 4 | icon: ruler 5 | --- 6 | 7 | The Connect sidecar is equipped to emit Prometheus metrics to provide observability into the health of the oracle sidecar. 8 | 9 | ### Metrics Emitted by Connect 10 | 11 | Connect will emit the following metrics from the oracle sidecar: 12 | 13 | - Liveness counter for every update cycle 14 | - Counter for the number of times a market has been updated 15 | - Price updates per ticker per provider 16 | - Aggregate price updates per ticker 17 | - Count for number of providers used in a price aggregation 18 | - Count for number of times a provider included a price used in aggregation 19 | - Connect binary build information 20 | 21 | ### Metrics Emitted by Application 22 | 23 | - Oracle client response latency 24 | - Number of Oracle responses 25 | - ABCI method latency 26 | - Size of messages 27 | - Aggregate ticker prices written to app state 28 | - Prices reported by each validator 29 | - Status of reports from each validator 30 | 31 | 32 | ### Need More Metrics? 33 | 34 | If you find yourself wanting more observability than what is provided, please open an issue on our [GitHub repo](https://github.com/skip-mev/connect/). -------------------------------------------------------------------------------- /docs/metrics/setup.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | description: How to set up Connect metrics 3 | title: Setup 4 | icon: shapes 5 | --- 6 | 7 | This document will guide you on how to utilize Connect's metrics. 8 | 9 | ## Prerequisites 10 | 11 | - You have a [Prometheus](https://prometheus.io/) instance 12 | - You have a [Grafana](https://grafana.com/) instance 13 | - You've configured a data source in Grafana for your Prometheus instance 14 | - You have set the metrics [configuration](/validators/configuration#env-vars) in the Connect sidecar 15 | 16 | If you're not sure how to set up Prometheus and Grafana, see this [guide](https://grafana.com/docs/grafana/latest/getting-started/get-started-grafana-prometheus/). 17 | 18 | ## Import Dashboards 19 | 20 | Import the following dashboard JSON files to view the available metrics in Grafana. If you do not know how to import a Grafana dashboard, see this [guide](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/import-dashboards/). 21 | 22 | ### Sidecar 23 | 24 | [Sidecar Dashboard JSON file](https://github.com/skip-mev/connect/v2/blob/main/grafana/provisioning/dashboards/side-car-dashboard.json) 25 | 26 | ### Application 27 | 28 | [Application Dashboard JSON file](https://github.com/skip-mev/connect/v2/blob/main/grafana/provisioning/dashboards/chain-dashboard.json) -------------------------------------------------------------------------------- /docs/snippets/quickstart.mdx: -------------------------------------------------------------------------------- 1 | To run Connect, which starts the service on the default port of `8080`, enter the following command: 2 | 3 | ```shell 4 | connect --market-map-endpoint=":" 5 | ``` -------------------------------------------------------------------------------- /docs/snippets/stargaze-quickstart-guide.mdx: -------------------------------------------------------------------------------- 1 | **The required version for Connect with Stargaze is `v1.0.12`.** 2 | 3 | You need to configure a custom API endpoint for use with the Osmosis provider, `https://rest.osmosis-1.interchain-apis.com`. 4 | Set the following `oracle.json` configuration file. **Keep the file path handy** as we will pass it into a flag when running Connect. 5 | 6 | ```json oracle.json 7 | { 8 | "providers": { 9 | "osmosis_api": { 10 | "api": { 11 | "endpoints": [ 12 | {"url": "https://rest.osmosis-1.interchain-apis.com"} 13 | ] 14 | } 15 | } 16 | } 17 | } 18 | ``` 19 | With the `oracle.json` file path, enter the following command to run Connect. 20 | 21 | ```shell 22 | connect \ 23 | --market-map-endpoint=":" \ 24 | --oracle-config path/to/oracle.json 25 | ``` -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | .dark #navbar { 2 | background-color: #121213; 3 | } 4 | -------------------------------------------------------------------------------- /docs/validators/advanced-setups.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | description: Using Connect with Advanced Validator Infrastructure 3 | title: Advanced Setups 4 | icon: plug 5 | --- 6 | 7 | ## Using Remote Signers 8 | 9 | Remote signers can be used with Connect, however only certain versions are compatible. 10 | 11 | ### Horcrux 12 | 13 | **Required Version:** v3.3.0+ https://github.com/strangelove-ventures/horcrux/releases 14 | 15 | #### With `Horcrux-Proxy` 16 | 17 | **Required Version:** v1.0.0+ https://github.com/strangelove-ventures/horcrux-proxy/releases 18 | 19 | ### TMKMS 20 | 21 | **Required Version:** v0.13.1+ https://github.com/iqlusioninc/tmkms/tags 22 | 23 | 24 | ## Using Distributed Validators 25 | 26 | Connect can be used within a distributed validator setup. To do so, simply apply the same `app.toml` changes to _each_ validator node. 27 | Head over to [configuration](configuration#application) to see the necessary application-side configurations. 28 | -------------------------------------------------------------------------------- /grafana/provisioning/dashboards/dashboards.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'default' 5 | orgId: 1 6 | folder: '' 7 | folderUid: '' 8 | type: file 9 | disableDeletion: false 10 | editable: true 11 | updateIntervalSeconds: 10 12 | options: 13 | path: /etc/grafana/provisioning/dashboards 14 | -------------------------------------------------------------------------------- /grafana/provisioning/datasources/prometheus.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | isDefault: true 9 | editable: true 10 | -------------------------------------------------------------------------------- /oracle/README.md: -------------------------------------------------------------------------------- 1 | # Oracle 2 | 3 | Responsibilities of the Oracle: 4 | 5 | * Spinning up the necessary services (e.g. the sidecar, the price fetchers, etc.). 6 | * Managing the lifecycle of the providers. 7 | * Determining the set of markets that need to be fetched and updating the providers accordingly. 8 | 9 | ## Configuration 10 | 11 | At a high level the oracle is configured with a `oracle.json` file that contains all providers that need to be instantiated. To read more about the configuration of `oracle.json`, please refer to the [oracle configuration documentation](../docs/validators/configuration.mdx). 12 | 13 | Each provider is instantiated using the `PriceAPIQueryHandlerFactory`, `PriceWebSocketQueryHandlerFactory`, and `MarketMapFactory` factory functions. Think of these as the constructors for the providers. 14 | 15 | * `PriceAPIQueryHandlerFactory` - This is used to create the API query handler for the provider - which is then passed into a base provider. 16 | * `PriceWebSocketQueryHandlerFactory` - This is used to create the WebSocket query handler for the provider - which is then passed into a base provider. 17 | * `MarketMapFactory` - This is used to create the market map provider. 18 | 19 | ## Lifecycle 20 | 21 | The oracle can be initialized with an option of `WithMarketMap` which allows each provider to be instantiated with a predetermined set of markets. If this option is not provided, the oracle will fetch the markets from the market map provider. **Both options can be set.** 22 | 23 | The oracle will then start each provider in a separate goroutine. Additionally, if the oracle has a market map provider, it will start a goroutine that will periodically fetch the markets from the market map provider and update the providers accordingly. 24 | 25 | All providers are running concurrently and will do so until the main context is canceled (what is passed into `Start`). If the oracle is canceled, it will cancel all providers and wait for them to finish before returning. 26 | 27 | -------------------------------------------------------------------------------- /oracle/config/metrics.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // MetricsConfig is the metrics configurations for the oracle. This configuration object specifically 8 | // exposes metrics pertaining to the oracle sidecar. To enable app side metrics, please see the app 9 | // configuration. 10 | type MetricsConfig struct { 11 | // PrometheusServerAddress is the address of the prometheus server that the oracle will expose 12 | // metrics to. 13 | PrometheusServerAddress string `json:"prometheusServerAddress"` 14 | 15 | Telemetry TelemetryConfig `json:"telemetry"` 16 | 17 | // Enabled indicates whether metrics should be enabled. 18 | Enabled bool `json:"enabled"` 19 | } 20 | 21 | type TelemetryConfig struct { 22 | // Toggle to disable opt-out telemetry 23 | Disabled bool `json:"disabled"` 24 | 25 | // Address of the remote server to push telemetry to 26 | PushAddress string `json:"pushAddress"` 27 | } 28 | 29 | // ValidateBasic performs basic validation of the config. 30 | func (c *MetricsConfig) ValidateBasic() error { 31 | if !c.Enabled { 32 | return nil 33 | } 34 | 35 | if len(c.PrometheusServerAddress) == 0 { 36 | return fmt.Errorf("must supply a non-empty prometheus server address if metrics are enabled") 37 | } 38 | 39 | return c.Telemetry.ValidateBasic() 40 | } 41 | 42 | // ValidateBasic performs basic validation of the config. 43 | func (c *TelemetryConfig) ValidateBasic() error { 44 | if c.Disabled { 45 | return nil 46 | } 47 | 48 | if len(c.PushAddress) == 0 { 49 | return fmt.Errorf("must supply a non-empty push address when telemetry is not disabled") 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /oracle/config/metrics_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/oracle/config" 9 | ) 10 | 11 | func TestMetricsConfig(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | config config.MetricsConfig 15 | expectedErr bool 16 | }{ 17 | { 18 | name: "good config with metrics", 19 | config: config.MetricsConfig{ 20 | Enabled: true, 21 | PrometheusServerAddress: "localhost:9090", 22 | Telemetry: config.TelemetryConfig{ 23 | Disabled: false, 24 | PushAddress: "localhost:9125", 25 | }, 26 | }, 27 | expectedErr: false, 28 | }, 29 | { 30 | name: "bad config with no prometheus server address", 31 | config: config.MetricsConfig{ 32 | Enabled: true, 33 | PrometheusServerAddress: "", 34 | }, 35 | expectedErr: true, 36 | }, 37 | { 38 | name: "bad config with no telemetry push address", 39 | config: config.MetricsConfig{ 40 | Enabled: true, 41 | Telemetry: config.TelemetryConfig{ 42 | Disabled: false, 43 | PushAddress: "", 44 | }, 45 | }, 46 | expectedErr: true, 47 | }, 48 | { 49 | name: "no metrics enabled", 50 | config: config.MetricsConfig{ 51 | Enabled: false, 52 | PrometheusServerAddress: "", 53 | }, 54 | expectedErr: false, 55 | }, 56 | { 57 | name: "telemetry disabled", 58 | config: config.MetricsConfig{ 59 | Enabled: false, 60 | PrometheusServerAddress: "", 61 | Telemetry: config.TelemetryConfig{}, 62 | }, 63 | expectedErr: false, 64 | }, 65 | } 66 | 67 | for _, tc := range testCases { 68 | t.Run(tc.name, func(t *testing.T) { 69 | err := tc.config.ValidateBasic() 70 | if tc.expectedErr { 71 | require.Error(t, err) 72 | } else { 73 | require.NoError(t, err) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /oracle/constants/chains.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | DYDX = "dydx" 5 | ETHEREUM = "ethereum" 6 | BASE = "base" 7 | ) 8 | -------------------------------------------------------------------------------- /oracle/interfaces.go: -------------------------------------------------------------------------------- 1 | package oracle 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/skip-mev/connect/v2/oracle/types" 8 | mmtypes "github.com/skip-mev/connect/v2/x/marketmap/types" 9 | ) 10 | 11 | // Oracle defines the expected interface for an oracle. It is consumed by the oracle server. 12 | // 13 | //go:generate mockery --name Oracle --filename mock_oracle.go 14 | type Oracle interface { 15 | IsRunning() bool 16 | GetLastSyncTime() time.Time 17 | GetPrices() types.Prices 18 | GetMarketMap() mmtypes.MarketMap 19 | Start(ctx context.Context) error 20 | Stop() 21 | } 22 | 23 | // PriceAggregator is an interface for aggregating prices from multiple providers. Implementations of PriceAggregator 24 | // should be made safe for concurrent use. 25 | // 26 | //go:generate mockery --name PriceAggregator 27 | type PriceAggregator interface { 28 | SetProviderPrices(provider string, prices types.Prices) 29 | UpdateMarketMap(mmtypes.MarketMap) 30 | AggregatePrices() 31 | GetPrices() types.Prices 32 | Reset() 33 | } 34 | 35 | // generalProvider is an interface for a provider that implements the base provider. 36 | type generalProvider interface { 37 | // Start starts the provider. 38 | Start(ctx context.Context) error 39 | // Name is the provider's name. 40 | Name() string 41 | } 42 | -------------------------------------------------------------------------------- /oracle/metrics/node.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "google.golang.org/grpc" 9 | "google.golang.org/grpc/credentials/insecure" 10 | 11 | "github.com/skip-mev/connect/v2/oracle/config" 12 | connectgrpc "github.com/skip-mev/connect/v2/pkg/grpc" 13 | 14 | "github.com/cosmos/cosmos-sdk/client/grpc/cmtservice" 15 | ) 16 | 17 | //go:generate mockery --name NodeClient --filename mock_node_client.go 18 | type NodeClient interface { 19 | DeriveNodeIdentifier() (string, error) 20 | } 21 | 22 | type NodeClientImpl struct { 23 | conn *grpc.ClientConn 24 | } 25 | 26 | func NewNodeClient(endpoint config.Endpoint) (NodeClient, error) { 27 | conn, err := connectgrpc.NewClient( 28 | endpoint.URL, 29 | grpc.WithTransportCredentials(insecure.NewCredentials()), 30 | grpc.WithNoProxy(), 31 | ) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &NodeClientImpl{ 37 | conn, 38 | }, nil 39 | } 40 | 41 | func (nc *NodeClientImpl) DeriveNodeIdentifier() (string, error) { 42 | svcclient := cmtservice.NewServiceClient(nc.conn) 43 | 44 | info, err := svcclient.GetNodeInfo(context.Background(), &cmtservice.GetNodeInfoRequest{}) 45 | if err != nil { 46 | return "", err 47 | } 48 | 49 | moniker := strings.ReplaceAll(info.DefaultNodeInfo.Moniker, " ", "-") 50 | network := info.DefaultNodeInfo.Network 51 | 52 | identifier := fmt.Sprintf("%s.%s", network, moniker) 53 | 54 | return identifier, nil 55 | } 56 | -------------------------------------------------------------------------------- /oracle/metrics/node_test.go: -------------------------------------------------------------------------------- 1 | package metrics_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "testing" 8 | 9 | p2p "github.com/cometbft/cometbft/proto/tendermint/p2p" 10 | cmtservice "github.com/cosmos/cosmos-sdk/client/grpc/cmtservice" 11 | "github.com/stretchr/testify/require" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/reflection" 14 | 15 | "github.com/skip-mev/connect/v2/oracle/config" 16 | oraclemetrics "github.com/skip-mev/connect/v2/oracle/metrics" 17 | ) 18 | 19 | // mocks a remote sdk grpc service. 20 | type mockServiceServer struct { 21 | cmtservice.ServiceServer 22 | } 23 | 24 | func (m *mockServiceServer) GetNodeInfo(_ context.Context, _ *cmtservice.GetNodeInfoRequest) (*cmtservice.GetNodeInfoResponse, error) { 25 | return &cmtservice.GetNodeInfoResponse{ 26 | DefaultNodeInfo: &p2p.DefaultNodeInfo{ 27 | Network: "neutron-1", 28 | Moniker: "some🫵😹node moniker", 29 | }, 30 | }, nil 31 | } 32 | 33 | func TestNodeClientImpl_DeriveNodeIdentifier(t *testing.T) { 34 | // mock the remote node 35 | srv := grpc.NewServer() 36 | mockSvcServer := &mockServiceServer{} 37 | cmtservice.RegisterServiceServer(srv, mockSvcServer) 38 | reflection.Register(srv) 39 | 40 | // let the os assign a port 41 | lis, err := net.Listen("tcp", "localhost:0") 42 | require.NoError(t, err) 43 | 44 | go func() { 45 | srv.Serve(lis) 46 | }() 47 | defer srv.Stop() 48 | 49 | // get the port from earlier 50 | _, port, err := net.SplitHostPort(lis.Addr().String()) 51 | require.NoError(t, err) 52 | 53 | // test conn 54 | endpoint := config.Endpoint{URL: fmt.Sprintf("localhost:%s", port)} 55 | nodeClient, err := oraclemetrics.NewNodeClient(endpoint) 56 | require.NoError(t, err) 57 | 58 | // test DeriveNodeIdentifier 59 | identifier, err := nodeClient.DeriveNodeIdentifier() 60 | require.NoError(t, err) 61 | require.Equal(t, "neutron-1.some🫵😹node-moniker", identifier) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/arrays/arrays.go: -------------------------------------------------------------------------------- 1 | package arrays 2 | 3 | // CheckEntryInArray checks if an entry is in an array, and returns true 4 | // and the entry if it is found. 5 | func CheckEntryInArray[T comparable](entry T, array []T) (value T, _ bool) { 6 | for _, e := range array { 7 | if e == entry { 8 | return e, true 9 | } 10 | } 11 | return value, false 12 | } 13 | -------------------------------------------------------------------------------- /pkg/arrays/arrays_test.go: -------------------------------------------------------------------------------- 1 | package arrays_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/skip-mev/connect/v2/pkg/arrays" 7 | ) 8 | 9 | func TestCheckEntryInArray(t *testing.T) { 10 | t.Parallel() 11 | tests := []struct { 12 | name string 13 | entry int 14 | array []int 15 | want bool 16 | }{ 17 | { 18 | name: "entry in array", 19 | entry: 1, 20 | array: []int{1, 2, 3}, 21 | want: true, 22 | }, 23 | { 24 | name: "entry not in array", 25 | entry: 4, 26 | array: []int{1, 2, 3}, 27 | want: false, 28 | }, 29 | { 30 | name: "empty array", 31 | entry: 1, 32 | array: []int{}, 33 | want: false, 34 | }, 35 | } 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | t.Parallel() 39 | 40 | if _, got := arrays.CheckEntryInArray(tt.entry, tt.array); got != tt.want { 41 | t.Errorf("CheckEntryInArray() = %v, want %v", got, tt.want) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/grpc/client.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/url" 7 | 8 | grpc "google.golang.org/grpc" 9 | ) 10 | 11 | // NewClient is a wrapper around the `grpc.NewClient` function. Which strips the 12 | // (`http` / `https`) schemes from the URL, and returns a new client using a 13 | // plain url (
:) as the target. 14 | func NewClient( 15 | target string, 16 | opts ...grpc.DialOption, 17 | ) (conn *grpc.ClientConn, err error) { 18 | // check if this is a host:port URI, if so continue, 19 | // otherwise, parse the URL and extract the host and port 20 | host, port, err := net.SplitHostPort(target) 21 | if err != nil { 22 | // parse the URL 23 | ip, err := url.Parse(target) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // extract the host and port 29 | host, port = ip.Hostname(), ip.Port() 30 | } 31 | 32 | // create a new client 33 | return grpc.NewClient( 34 | fmt.Sprintf("%s:%s", host, port), 35 | opts..., 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/grpc/client_test.go: -------------------------------------------------------------------------------- 1 | package grpc_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/credentials/insecure" 12 | "google.golang.org/grpc/reflection" 13 | reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" 14 | 15 | connectgrpc "github.com/skip-mev/connect/v2/pkg/grpc" 16 | ) 17 | 18 | func TestClient(t *testing.T) { 19 | // spin up a mock grpc-server + test connecting to it via diff addresses 20 | srv := grpc.NewServer() 21 | 22 | // listen on a random open port 23 | lis, err := net.Listen("tcp", "localhost:0") 24 | if err != nil { 25 | t.Fatalf("failed to listen: %v", err) 26 | } 27 | // register reflection service on the server 28 | reflection.Register(srv) 29 | 30 | // start the server 31 | go func() { 32 | srv.Serve(lis) 33 | }() 34 | 35 | _, port, err := net.SplitHostPort(lis.Addr().String()) 36 | if err != nil { 37 | t.Fatalf("failed to parse address: %v", err) 38 | } 39 | 40 | t.Run("try dialing via non supported GRPC target URL (i.e tcp prefix)", func(t *testing.T) { 41 | // try dialing via non supported GRPC target URL (i.e tcp prefix) 42 | client, err := connectgrpc.NewClient(fmt.Sprintf("tcp://localhost:%s", port), grpc.WithTransportCredentials(insecure.NewCredentials())) 43 | require.NoError(t, err) 44 | 45 | // ping the server 46 | _, err = reflectionpb.NewServerReflectionClient(client).ServerReflectionInfo(context.Background()) 47 | require.NoError(t, err) 48 | }) 49 | 50 | t.Run("try dialing via supported GRPC target URL (i.e host:port)", func(t *testing.T) { 51 | // try dialing via supported GRPC target URL (i.e host:port) 52 | client, err := connectgrpc.NewClient(fmt.Sprintf("localhost:%s", port), grpc.WithTransportCredentials(insecure.NewCredentials())) 53 | require.NoError(t, err) 54 | 55 | // ping the server 56 | _, err = reflectionpb.NewServerReflectionClient(client).ServerReflectionInfo(context.Background()) 57 | require.NoError(t, err) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/http/address.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "net" 4 | 5 | func IsValidAddress(address string) bool { 6 | host, port, err := net.SplitHostPort(address) 7 | if err != nil { 8 | return false 9 | } 10 | 11 | if host == "" || port == "" { 12 | return false 13 | } 14 | 15 | return true 16 | } 17 | -------------------------------------------------------------------------------- /pkg/http/round_tripper_with_headers_test.go: -------------------------------------------------------------------------------- 1 | package http_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | connecthttp "github.com/skip-mev/connect/v2/pkg/http" 11 | ) 12 | 13 | func TestRoundTripperWithHeaders(t *testing.T) { 14 | expectedHeaderFields := map[string]string{ 15 | "X-Api-Key": "test", 16 | } 17 | 18 | rt := &customRoundTripper{ 19 | expectedHeaderFields: expectedHeaderFields, 20 | } 21 | 22 | rtWithHeaders := connecthttp.NewRoundTripperWithHeaders(rt, connecthttp.WithAuthentication("X-Api-Key", "test")) 23 | 24 | client := &http.Client{ 25 | Transport: rtWithHeaders, 26 | } 27 | 28 | req, err := http.NewRequest(http.MethodGet, "http://test.com", nil) 29 | require.NoError(t, err) 30 | 31 | // Make the request 32 | _, err = client.Do(req) 33 | require.NoError(t, err) 34 | } 35 | 36 | type customRoundTripper struct { 37 | expectedHeaderFields map[string]string 38 | } 39 | 40 | func (c *customRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 41 | for k, v := range c.expectedHeaderFields { 42 | if req.Header.Get(k) != v { 43 | return nil, fmt.Errorf("expected header %s to be %s, got %s", k, v, req.Header.Get(k)) 44 | } 45 | } 46 | return &http.Response{}, nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/json/json.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // IsValid checks if the given byte array is valid JSON. 9 | // If the byte array is 0 length, this is a valid empty JSON object. 10 | func IsValid(jsonBz []byte) error { 11 | if len(jsonBz) == 0 { 12 | return nil 13 | } 14 | 15 | var checkStruct map[string]interface{} 16 | if err := json.Unmarshal(jsonBz, &checkStruct); err != nil { 17 | return fmt.Errorf("unable to unmarshal string to json: %w", err) 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/json/json_test.go: -------------------------------------------------------------------------------- 1 | package json_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/pkg/json" 9 | ) 10 | 11 | func TestIsValid(t *testing.T) { 12 | type testCase struct { 13 | name string 14 | bz []byte 15 | expectErr bool 16 | } 17 | 18 | testCases := []testCase{ 19 | { 20 | name: "valid empty", 21 | bz: []byte{}, 22 | expectErr: false, 23 | }, 24 | { 25 | name: "valid basic JSON", 26 | bz: []byte(`{"key": "value"}`), 27 | expectErr: false, 28 | }, 29 | { 30 | name: "invalid basic JSON missing quotation", 31 | bz: []byte(`{"key": "value}`), 32 | expectErr: true, 33 | }, 34 | { 35 | name: "valid JSON array", 36 | bz: []byte(`{ 37 | "arr": [ 38 | { 39 | "key1": "value1a", 40 | "key2": "value2a", 41 | "key3": "value3a" 42 | }, 43 | { 44 | "key1": "value1b", 45 | "key2": "value2b", 46 | "key3": "value3b" 47 | } 48 | ] 49 | }`), 50 | expectErr: false, 51 | }, 52 | { 53 | name: "invalid JSON array - extra comma", 54 | bz: []byte(`{ 55 | "arr": [ 56 | { 57 | "key1": "value1a", 58 | "key2": "value2a", 59 | "key3": "value3a", 60 | }, 61 | { 62 | "key1": "value1b", 63 | "key2": "value2b", 64 | "key3": "value3b" 65 | } 66 | ] 67 | }`), 68 | expectErr: true, 69 | }, 70 | } 71 | 72 | for _, tc := range testCases { 73 | t.Run(tc.name, func(t *testing.T) { 74 | err := json.IsValid(tc.bz) 75 | if tc.expectErr { 76 | require.Error(t, err) 77 | return 78 | } 79 | 80 | require.NoError(t, err) 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/math/testutils.go: -------------------------------------------------------------------------------- 1 | package math 2 | 3 | import ( 4 | "math/big" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | // VerifyPrice verifies that the expected price matches the actual price within an acceptable delta. 11 | func VerifyPrice( 12 | t *testing.T, 13 | expected, 14 | actual *big.Int, 15 | acceptableDelta float64, 16 | ) { 17 | t.Helper() 18 | 19 | zero := big.NewInt(0) 20 | if expected.Cmp(zero) == 0 { 21 | require.Equal(t, zero, actual) 22 | return 23 | } 24 | 25 | var diff *big.Float 26 | if expected.Cmp(actual) > 0 { 27 | diff = new(big.Float).Sub(new(big.Float).SetInt(expected), new(big.Float).SetInt(actual)) 28 | } else { 29 | diff = new(big.Float).Sub(new(big.Float).SetInt(actual), new(big.Float).SetInt(expected)) 30 | } 31 | 32 | scaledDiff := new(big.Float).Quo(diff, new(big.Float).SetInt(expected)) 33 | delta, _ := scaledDiff.Float64() 34 | t.Logf("expected price: %s; actual price: %s; diff %s", expected.String(), actual.String(), diff.String()) 35 | t.Logf("acceptable delta: %.25f; actual delta: %.25f", acceptableDelta, delta) 36 | 37 | switch { 38 | case delta == 0: 39 | // If the difference between the expected and actual price is 0, the prices match. 40 | // No need for a delta comparison. 41 | return 42 | case delta <= acceptableDelta: 43 | // If the difference between the expected and actual price is within the acceptable delta, 44 | // the prices match. 45 | return 46 | default: 47 | // If the difference between the expected and actual price is greater than the acceptable delta, 48 | // the prices do not match. 49 | require.Fail(t, "expected price does not match the actual price; delta is too large") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/math/voteweighted/interfaces.go: -------------------------------------------------------------------------------- 1 | package voteweighted 2 | 3 | import ( 4 | "context" 5 | 6 | "cosmossdk.io/math" 7 | sdk "github.com/cosmos/cosmos-sdk/types" 8 | stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" 9 | "github.com/cosmos/interchain-security/v6/x/ccv/consumer/types" 10 | ) 11 | 12 | // ValidatorStore defines the interface contract required for calculating stake-weighted median 13 | // prices + total voting power for a given currency pair. 14 | // 15 | //go:generate mockery --srcpkg=github.com/cosmos/cosmos-sdk/x/staking/types --name ValidatorI --filename mock_validator.go 16 | //go:generate mockery --name ValidatorStore --filename mock_validator_store.go 17 | type ValidatorStore interface { 18 | ValidatorByConsAddr(ctx context.Context, addr sdk.ConsAddress) (stakingtypes.ValidatorI, error) 19 | TotalBondedTokens(ctx context.Context) (math.Int, error) 20 | } 21 | 22 | // CCValidatorStore defines the interface contract required for the cross chain validator consumer store. 23 | // 24 | //go:generate mockery --name CCValidatorStore --filename mock_cc_validator_store.go 25 | type CCValidatorStore interface { 26 | GetAllCCValidator(ctx sdk.Context) []types.CrossChainValidator 27 | GetCCValidator(ctx sdk.Context, addr []byte) (types.CrossChainValidator, bool) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/slices/slices.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | // Chunk chunks a slice into batches of chunkSize. 4 | // example: {1,2,3,4,5}, chunkSize = 2 -> {1,2}, {3,4}, {5} 5 | func Chunk[T any](input []T, chunkSize int) [][]T { 6 | if len(input) <= chunkSize { 7 | return [][]T{input} 8 | } 9 | var chunks [][]T 10 | for i := 0; i < len(input); i += chunkSize { 11 | end := i + chunkSize 12 | 13 | if end > len(input) { 14 | end = len(input) 15 | } 16 | 17 | chunks = append(chunks, input[i:end]) 18 | } 19 | return chunks 20 | } 21 | -------------------------------------------------------------------------------- /pkg/slices/slices_test.go: -------------------------------------------------------------------------------- 1 | package slices_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/pkg/slices" 9 | ) 10 | 11 | func TestChunkSlice(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | input []string 15 | chunkSize int 16 | expected [][]string 17 | }{ 18 | { 19 | name: "Empty slice", 20 | input: []string{}, 21 | chunkSize: 3, 22 | expected: [][]string{{}}, 23 | }, 24 | { 25 | name: "Slice smaller than chunk size", 26 | input: []string{"a", "b"}, 27 | chunkSize: 3, 28 | expected: [][]string{{"a", "b"}}, 29 | }, 30 | { 31 | name: "Slice equal to chunk size", 32 | input: []string{"a", "b", "c"}, 33 | chunkSize: 3, 34 | expected: [][]string{{"a", "b", "c"}}, 35 | }, 36 | { 37 | name: "Slice larger than chunk size", 38 | input: []string{"a", "b", "c", "d", "e"}, 39 | chunkSize: 2, 40 | expected: [][]string{{"a", "b"}, {"c", "d"}, {"e"}}, 41 | }, 42 | { 43 | name: "Chunk size of 1", 44 | input: []string{"a", "b", "c"}, 45 | chunkSize: 1, 46 | expected: [][]string{{"a"}, {"b"}, {"c"}}, 47 | }, 48 | { 49 | name: "Large slice with uneven chunks", 50 | input: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}, 51 | chunkSize: 3, 52 | expected: [][]string{{"1", "2", "3"}, {"4", "5", "6"}, {"7", "8", "9"}, {"10"}}, 53 | }, 54 | } 55 | 56 | for _, tc := range testCases { 57 | t.Run(tc.name, func(t *testing.T) { 58 | result := slices.Chunk(tc.input, tc.chunkSize) 59 | require.Equal(t, tc.expected, result) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /pkg/sync/sync.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Closer implements a primitive to close a channel that signals process 8 | // termination while allowing a caller to call Close multiple times safely. It 9 | // should be used in cases where guarantees cannot be made about when and how 10 | // many times closure is executed. 11 | type Closer struct { 12 | closeOnce sync.Once 13 | doneCh chan struct{} 14 | cb func() 15 | } 16 | 17 | // NewCloser returns a reference to a new Closer. 18 | func NewCloser() *Closer { 19 | return &Closer{doneCh: make(chan struct{})} 20 | } 21 | 22 | // WithCallback adds additional logic to be triggered on the closure of the Closer. 23 | func (c *Closer) WithCallback(cb func()) *Closer { 24 | c.cb = cb 25 | return c 26 | } 27 | 28 | // Done returns the internal done channel allowing the caller either block or wait 29 | // for the Closer to be terminated/closed. 30 | func (c *Closer) Done() <-chan struct{} { 31 | return c.doneCh 32 | } 33 | 34 | // Close gracefully closes the Closer. A caller should only call Close once, but 35 | // it is safe to call it successive times. 36 | func (c *Closer) Close() { 37 | c.closeOnce.Do(func() { 38 | // execute call-back 39 | if c.cb != nil { 40 | c.cb() 41 | } 42 | close(c.doneCh) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /proto/buf.gen.gogo.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | plugins: 3 | - name: gocosmos 4 | out: .. 5 | opt: plugins=grpc,Mgoogle/protobuf/any.proto=github.com/cosmos/cosmos-sdk/codec/types 6 | - name: grpc-gateway 7 | out: .. 8 | opt: logtostderr=true,allow_colon_final_segments=true 9 | -------------------------------------------------------------------------------- /proto/buf.gen.pulsar.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | managed: 4 | enabled: true 5 | go_package_prefix: 6 | default: cosmossdk.io/api 7 | except: 8 | - buf.build/googleapis/googleapis 9 | - buf.build/cosmos/gogo-proto 10 | - buf.build/cosmos/cosmos-proto 11 | override: 12 | buf.build/skip-mev/slinky: github.com/skip-mev/connect/v2/api 13 | plugins: 14 | - name: go-pulsar 15 | out: ../api 16 | opt: paths=source_relative 17 | - name: go-grpc 18 | out: ../api 19 | opt: paths=source_relative -------------------------------------------------------------------------------- /proto/buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v1 3 | deps: 4 | - remote: buf.build 5 | owner: cosmos 6 | repository: cosmos-proto 7 | commit: 1935555c206d4afb9e94615dfd0fad31 8 | digest: shake256:c74d91a3ac7ae07d579e90eee33abf9b29664047ac8816500cf22c081fec0d72d62c89ce0bebafc1f6fec7aa5315be72606717740ca95007248425102c365377 9 | - remote: buf.build 10 | owner: cosmos 11 | repository: cosmos-sdk 12 | commit: 954f7b05f38440fc8250134b15adec47 13 | digest: shake256:2ab4404fd04a7d1d52df0e2d0f2d477a3d83ffd88d876957bf3fedfd702c8e52833d65b3ce1d89a3c5adf2aab512616b0e4f51d8463f07eda9a8a3317ee3ac54 14 | - remote: buf.build 15 | owner: cosmos 16 | repository: gogo-proto 17 | commit: 88ef6483f90f478fb938c37dde52ece3 18 | digest: shake256:89c45df2aa11e0cff97b0d695436713db3d993d76792e9f8dc1ae90e6ab9a9bec55503d48ceedd6b86069ab07d3041b32001b2bfe0227fa725dd515ff381e5ba 19 | - remote: buf.build 20 | owner: googleapis 21 | repository: googleapis 22 | commit: 7e6f6e774e29406da95bd61cdcdbc8bc 23 | digest: shake256:fe43dd2265ea0c07d76bd925eeba612667cf4c948d2ce53d6e367e1b4b3cb5fa69a51e6acb1a6a50d32f894f054a35e6c0406f6808a483f2752e10c866ffbf73 24 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | name: buf.build/skip-mev/connect 3 | 4 | deps: 5 | - buf.build/cosmos/cosmos-proto 6 | - buf.build/cosmos/cosmos-sdk:v0.47.0 7 | - buf.build/cosmos/gogo-proto 8 | - buf.build/googleapis/googleapis 9 | breaking: 10 | use: 11 | - FILE 12 | lint: 13 | use: 14 | - DEFAULT 15 | - COMMENTS 16 | - FILE_LOWER_SNAKE_CASE 17 | except: 18 | - SERVICE_SUFFIX 19 | - RPC_REQUEST_STANDARD_NAME 20 | - COMMENT_FIELD 21 | - FIELD_LOWER_SNAKE_CASE 22 | - PACKAGE_DIRECTORY_MATCH 23 | -------------------------------------------------------------------------------- /proto/connect/abci/v2/vote_extensions.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package connect.abci.v2; 3 | 4 | option go_package = "github.com/skip-mev/connect/v2/abci/ve/types"; 5 | 6 | // OracleVoteExtension defines the vote extension structure for oracle prices. 7 | message OracleVoteExtension { 8 | // Prices defines a map of id(CurrencyPair) -> price.Bytes() . i.e. 1 -> 9 | // 0x123.. (bytes). Notice the `id` function is determined by the 10 | // `CurrencyPairIDStrategy` used in the VoteExtensionHandler. 11 | map prices = 1; 12 | } 13 | -------------------------------------------------------------------------------- /proto/connect/marketmap/module/v2/module.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package connect.marketmap.module.v2; 4 | 5 | import "cosmos/app/v1alpha1/module.proto"; 6 | 7 | // Module is the config object of the builder module. 8 | message Module { 9 | option (cosmos.app.v1alpha1.module) = { 10 | go_import : "github.com/skip-mev/connect/v2/x/marketmap" 11 | }; 12 | 13 | // Authority defines the custom module authority. If not set, defaults to the 14 | // governance module. 15 | string authority = 1; 16 | 17 | // HooksOrder specifies the order of marketmap hooks and should be a list 18 | // of module names which provide a marketmap hooks instance. If no order is 19 | // provided, then hooks will be applied in alphabetical order of module names. 20 | repeated string hooks_order = 2; 21 | } -------------------------------------------------------------------------------- /proto/connect/marketmap/v2/genesis.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package connect.marketmap.v2; 3 | 4 | import "gogoproto/gogo.proto"; 5 | import "connect/marketmap/v2/market.proto"; 6 | import "connect/marketmap/v2/params.proto"; 7 | 8 | option go_package = "github.com/skip-mev/connect/v2/x/marketmap/types"; 9 | 10 | // GenesisState defines the x/marketmap module's genesis state. 11 | message GenesisState { 12 | // MarketMap defines the global set of market configurations for all providers 13 | // and markets. 14 | MarketMap market_map = 1 [ (gogoproto.nullable) = false ]; 15 | 16 | // LastUpdated is the last block height that the market map was updated. 17 | // This field can be used as an optimization for clients checking if there 18 | // is a new update to the map. 19 | uint64 last_updated = 2; 20 | 21 | // Params are the parameters for the x/marketmap module. 22 | Params params = 3 [ (gogoproto.nullable) = false ]; 23 | } 24 | -------------------------------------------------------------------------------- /proto/connect/marketmap/v2/params.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package connect.marketmap.v2; 3 | 4 | option go_package = "github.com/skip-mev/connect/v2/x/marketmap/types"; 5 | 6 | // Params defines the parameters for the x/marketmap module. 7 | message Params { 8 | // MarketAuthorities is the list of authority accounts that are able to 9 | // control updating the marketmap. 10 | repeated string market_authorities = 1; 11 | 12 | // Admin is an address that can remove addresses from the MarketAuthorities 13 | // list. Only governance can add to the MarketAuthorities or change the Admin. 14 | string admin = 2; 15 | } 16 | -------------------------------------------------------------------------------- /proto/connect/oracle/module/v2/module.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package connect.oracle.module.v2; 4 | 5 | import "cosmos/app/v1alpha1/module.proto"; 6 | 7 | // Module is the config object of the builder module. 8 | message Module { 9 | option (cosmos.app.v1alpha1.module) = { 10 | go_import : "github.com/skip-mev/connect/v2/x/oracle" 11 | }; 12 | 13 | // Authority defines the custom module authority. If not set, defaults to the 14 | // governance module. 15 | string authority = 1; 16 | } -------------------------------------------------------------------------------- /proto/connect/types/v2/currency_pair.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package connect.types.v2; 3 | 4 | import "gogoproto/gogo.proto"; 5 | 6 | option go_package = "github.com/skip-mev/connect/v2/pkg/types"; 7 | 8 | // CurrencyPair is the standard representation of a pair of assets, where one 9 | // (Base) is priced in terms of the other (Quote) 10 | message CurrencyPair { 11 | option (gogoproto.goproto_stringer) = false; 12 | option (gogoproto.stringer) = false; 13 | 14 | string Base = 1; 15 | string Quote = 2; 16 | } 17 | -------------------------------------------------------------------------------- /providers/apis/binance/README.md: -------------------------------------------------------------------------------- 1 | # Binance Provider 2 | 3 | ## Overview 4 | 5 | The Binance provider is used to fetch the spot price for cryptocurrencies from the [Binance API](https://binance-docs.github.io/apidocs/spot/en/#general-info). 6 | 7 | ## Supported Pairs 8 | 9 | To determine the pairs (in the form `BASEQUOTE`) currencies that the Binance provider supports, you can run the following command: 10 | 11 | ```bash 12 | $ curl -X GET https://api.binance.vision/api/v3/ticker/price 13 | ``` 14 | -------------------------------------------------------------------------------- /providers/apis/bitstamp/README.md: -------------------------------------------------------------------------------- 1 | # Bitstamp Provider 2 | 3 | ## Overview 4 | 5 | The Bitstamp provider is used to fetch the spot price for cryptocurrencies from the [Bitstamp API](https://www.bitstamp.net/api/). As standard, all clients can make 400 requests per second. There is a default limit threshold of 10,000 requests per 10 minutes in place. 6 | -------------------------------------------------------------------------------- /providers/apis/bitstamp/utils.go: -------------------------------------------------------------------------------- 1 | package bitstamp 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | // NOTE: All documentation for this file can be located on the Bitstamp GitHub 10 | // API documentation: https://www.bitstamp.net/api/v2/ticker/. This 11 | // API does not require a subscription to use (i.e. No API key is required). 12 | 13 | const ( 14 | // Name is the name of the Bitstamp provider. 15 | Name = "bitstamp_api" 16 | 17 | // URL is the base URL of the Bitstamp API. The URL returns all prices, some 18 | // of which are not needed. 19 | URL = "https://www.bitstamp.net/api/v2/ticker/" 20 | ) 21 | 22 | // DefaultAPIConfig is the default configuration for the Bitstamp API. 23 | var DefaultAPIConfig = config.APIConfig{ 24 | Name: Name, 25 | Atomic: true, 26 | Enabled: true, 27 | Timeout: 3000 * time.Millisecond, 28 | Interval: 3000 * time.Millisecond, 29 | ReconnectTimeout: 2000 * time.Millisecond, 30 | MaxQueries: 1, 31 | Endpoints: []config.Endpoint{{URL: URL}}, 32 | } 33 | 34 | // MarketTickerResponse is the expected response returned by the Bitstamp API. 35 | // 36 | // ex. 37 | // 38 | // [ 39 | // 40 | // { 41 | // "ask": "2211.00", 42 | // "bid": "2188.97", 43 | // "high": "2811.00", 44 | // "last": "2211.00", 45 | // "low": "2188.97", 46 | // "open": "2211.00", 47 | // "open_24": "2211.00", 48 | // "pair": "BTC/USD", 49 | // "percent_change_24": "13.57", 50 | // "side": "0", 51 | // "timestamp": "1643640186", 52 | // "volume": "213.26801100", 53 | // "vwap": "2189.80" 54 | // } 55 | // 56 | // ] 57 | // 58 | // ref: https://www.bitstamp.net/api/v2/ticker/ 59 | type MarketTickerResponse []MarketTickerData 60 | 61 | // MarketTickerData is the data returned by the Bitstamp API. 62 | type MarketTickerData struct { 63 | Last string `json:"last"` 64 | Pair string `json:"pair"` 65 | } 66 | -------------------------------------------------------------------------------- /providers/apis/coinbase/README.md: -------------------------------------------------------------------------------- 1 | # Coinbase Provider 2 | 3 | ## Overview 4 | 5 | The Coinbase provider is used to fetch the spot price for cryptocurrencies from the [Coinbase API](https://docs.cloud.coinbase.com/sign-in-with-coinbase/docs/api-prices#get-spot-price). 6 | -------------------------------------------------------------------------------- /providers/apis/coinbase/utils.go: -------------------------------------------------------------------------------- 1 | package coinbase 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | // NOTE: All documentation for this file can be located on the Coinbase 10 | // API documentation: https://docs.cloud.coinbase.com/sign-in-with-coinbase/docs/api-prices#get-spot-price. This 11 | // API does not require a subscription to use (i.e. No API key is required). 12 | 13 | const ( 14 | // Name is the name of the Coinbase provider. 15 | Name = "coinbase_api" 16 | 17 | // URL is the base URL of the Coinbase API. This includes the base and quote 18 | // currency pairs that need to be inserted into the URL. 19 | URL = "https://api.coinbase.com/v2/prices/%s/spot" 20 | ) 21 | 22 | // DefaultAPIConfig is the default configuration for the Coinbase API. 23 | var DefaultAPIConfig = config.APIConfig{ 24 | Name: Name, 25 | Atomic: false, 26 | Enabled: true, 27 | Timeout: 3000 * time.Millisecond, 28 | Interval: 100 * time.Millisecond, 29 | ReconnectTimeout: 2000 * time.Millisecond, 30 | MaxQueries: 1, 31 | Endpoints: []config.Endpoint{{URL: URL}}, 32 | } 33 | 34 | type ( 35 | // CoinBaseResponse is the expected response returned by the Coinbase API. 36 | // The response is json formatted. 37 | // Response format: 38 | // 39 | // { 40 | // "data": { 41 | // "amount": "1020.25", 42 | // "currency": "USD" 43 | // } 44 | // } 45 | CoinBaseResponse struct { //nolint 46 | Data CoinBaseData `json:"data"` 47 | } 48 | 49 | // CoinBaseData is the data returned by the Coinbase API. 50 | CoinBaseData struct { //nolint 51 | Amount string `json:"amount"` 52 | Currency string `json:"currency"` 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /providers/apis/coingecko/README.md: -------------------------------------------------------------------------------- 1 | # CoinGecko Provider 2 | 3 | ## Overview 4 | 5 | The CoinGecko provider is used to fetch the spot price for cryptocurrencies from the [CoinGecko API](https://www.coingecko.com/en/api). This provider can be configured to fetch with or without an API key. Note that without an API key, it is very likely that the CoinGecko API will rate limit your requests. The CoinGecko API fetches an aggregated TWAP price any given currency pair. 6 | 7 | ## Supported Bases 8 | 9 | To determine the base currencies that the CoinGecko provider supports, you can run the following command: 10 | 11 | ```bash 12 | $ curl -X GET https://api.coingecko.com/api/v3/coins/list 13 | ``` 14 | 15 | ## Supported Quotes 16 | 17 | To determine the quote currencies that the CoinGecko provider supports, you can run the following command: 18 | 19 | ```bash 20 | $ curl -X GET https://api.coingecko.com/api/v3/simple/supported_vs_currencies 21 | ``` 22 | -------------------------------------------------------------------------------- /providers/apis/coinmarketcap/README.md: -------------------------------------------------------------------------------- 1 | # CoinMarketCap Provider 2 | 3 | ## Overview 4 | 5 | The CoinMarketCap provider is used to fetch the spot price for cryptocurrencies from the [CoinMarketCap API](https://coinmarketcap.com/api/). This provider can only be configured to fetch with an API key. This API is good for benchmarking prices from exchanges and the index price of the sidecar. 6 | -------------------------------------------------------------------------------- /providers/apis/defi/ethmulticlient/utils.go: -------------------------------------------------------------------------------- 1 | package ethmulticlient 2 | 3 | import "github.com/ethereum/go-ethereum/rpc" 4 | 5 | // EthBlockNumberBatchElem returns an initialized BatchElem for the eth_blockNumber call. 6 | func EthBlockNumberBatchElem() rpc.BatchElem { 7 | var result string 8 | return rpc.BatchElem{ 9 | Method: "eth_blockNumber", 10 | Result: &result, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /providers/apis/defi/osmosis/types_test.go: -------------------------------------------------------------------------------- 1 | package osmosis_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/providers/apis/defi/osmosis" 9 | ) 10 | 11 | func TestCreateURL(t *testing.T) { 12 | type args struct { 13 | baseURL string 14 | poolID uint64 15 | baseAsset string 16 | quoteAsset string 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | want string 22 | wantErr bool 23 | }{ 24 | { 25 | name: "basic", 26 | args: args{ 27 | baseURL: "http://localhost", 28 | poolID: 1, 29 | baseAsset: "base", 30 | quoteAsset: "quote", 31 | }, 32 | want: "http://localhost/osmosis/poolmanager/v2/pools/1/prices?base_asset_denom=base"e_asset_denom" + 33 | "=quote", 34 | wantErr: false, 35 | }, 36 | } 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | got, err := osmosis.CreateURL(tt.args.baseURL, tt.args.poolID, tt.args.baseAsset, tt.args.quoteAsset) 40 | if tt.wantErr { 41 | require.Error(t, err) 42 | return 43 | } 44 | 45 | require.NoError(t, err) 46 | require.Equal(t, tt.want, got) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /providers/apis/defi/raydium/README.md: -------------------------------------------------------------------------------- 1 | # Raydium Provider 2 | 3 | The Raydium provider fetches prices from the Raydium dex via JSON-RPC requests to Solana nodes. 4 | 5 | ## How It Works 6 | 7 | For each ticker (i.e. RAY/SOL), we query 4 accounts: 8 | 9 | * BaseTokenVault 10 | * QuoteTokenVault 11 | * AMMInfo 12 | * OpenOrders 13 | 14 | To calculate the price, we need to get the base and quote token balances, subtract PNL feels, and add the value of open orders. 15 | 16 | With the above values, we calculate the price by dividing quote / base and multiplying by the scaling factor. 17 | 18 | -------------------------------------------------------------------------------- /providers/apis/defi/types/block_age.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "time" 4 | 5 | // BlockAgeChecker is a utility type to check if incoming block heights are validly updating. 6 | // If the block heights are not increasing and the time since the last update has exceeded 7 | // a configurable duration, this type will report that the updates are invalid. 8 | type BlockAgeChecker struct { 9 | lastHeight uint64 10 | lastTimeStamp time.Time 11 | maxAge time.Duration 12 | } 13 | 14 | // NewBlockAgeChecker returns a zeroed BlockAgeChecker using the provided maxAge. 15 | func NewBlockAgeChecker(maxAge time.Duration) BlockAgeChecker { 16 | return BlockAgeChecker{ 17 | lastHeight: 0, 18 | lastTimeStamp: time.Now(), 19 | maxAge: maxAge, 20 | } 21 | } 22 | 23 | // IsHeightValid returns true if: 24 | // - the new height is greater than the last height OR 25 | // - the time past the last block height update is less than the configured max age 26 | // returns false if: 27 | // - the time is past the configured max age. 28 | func (bc *BlockAgeChecker) IsHeightValid(newHeight uint64) bool { 29 | now := time.Now() 30 | 31 | if newHeight > bc.lastHeight { 32 | bc.lastHeight = newHeight 33 | bc.lastTimeStamp = now 34 | return true 35 | } 36 | 37 | if now.Sub(bc.lastTimeStamp) > bc.maxAge { 38 | return false 39 | } 40 | 41 | return true 42 | } 43 | -------------------------------------------------------------------------------- /providers/apis/defi/types/block_age_test.go: -------------------------------------------------------------------------------- 1 | package types_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/skip-mev/connect/v2/providers/apis/defi/types" 10 | ) 11 | 12 | func TestBlockAgeChecker_IsHeightValid(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | lastHeight uint64 16 | waitTime time.Duration 17 | maxAge time.Duration 18 | newHeight uint64 19 | isValid bool 20 | }{ 21 | { 22 | name: "valid 0s no timeout", 23 | lastHeight: 0, 24 | waitTime: 0, 25 | maxAge: 10 * time.Minute, 26 | newHeight: 0, 27 | isValid: true, 28 | }, 29 | { 30 | name: "valid new height no timeout", 31 | lastHeight: 0, 32 | waitTime: 0, 33 | maxAge: 10 * time.Minute, 34 | newHeight: 0, 35 | isValid: true, 36 | }, 37 | { 38 | name: "invalid 0s due to timeout", 39 | lastHeight: 0, 40 | waitTime: 10 * time.Millisecond, 41 | maxAge: 1 * time.Millisecond, 42 | newHeight: 0, 43 | isValid: false, 44 | }, 45 | { 46 | name: "valid timeout but block height increase", 47 | lastHeight: 0, 48 | waitTime: 10 * time.Millisecond, 49 | maxAge: 1 * time.Millisecond, 50 | newHeight: 1, 51 | isValid: true, 52 | }, 53 | } 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | bc := types.NewBlockAgeChecker(tt.maxAge) 57 | 58 | got := bc.IsHeightValid(tt.lastHeight) 59 | require.True(t, got) 60 | time.Sleep(tt.waitTime) 61 | 62 | got = bc.IsHeightValid(tt.newHeight) 63 | require.Equal(t, tt.isValid, got) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /providers/apis/defi/uniswapv3/README.md: -------------------------------------------------------------------------------- 1 | # Uniswap v3 API Provider 2 | 3 | > Please read over the [Uniswap v3 documentation](https://blog.uniswap.org/uniswap-v3-math-primer) to understand the basics of Uniswap v3. 4 | 5 | ## Overview 6 | 7 | The Uniswap v3 API Provider allows you to interact with the Uniswap v3 pools - otherwise known as concentrated liquidity pools (CLPs) - on the Ethereum blockchain. The provider utilizes JSON-RPC to interact with an ethereum node - batching multiple requests into a single HTTP request to reduce latency and improve performance. 8 | 9 | Uniswap v3 shows the current price of the pool in `slot0` of the pool contract. `slot0` is where most of the commonly accessed values are stored, making it a good starting point for data collection. You can get the price from two places; either from the `sqrtPriceX96` or calculating the price from the pool `tick` value. Using `sqrtPriceX96` should be preferred over calculating the price from the current tick, because the current tick may lose precision due to the integer constraints. As such, this provider uses the `sqrtPriceX96` value to calculate the price of the pool. 10 | 11 | Based on the [analysis](https://docs.chainstack.com/docs/http-batch-request-vs-multicall-contract#performance-comparison) of various approaches for querying EVM state, this implementation utilizes `BatchCallContext` available on any client that implements the go-ethereum's `ethclient` interface. This allows for multiple requests to be batched into a single HTTP request, reducing latency and improving performance. This is preferable to using the `multicall` contract, which is a contract that aggregates multiple calls into a single call. 12 | 13 | To generate the ABI for the Uniswap v3 pool contract, you can use the `abigen` tool provided by the go-ethereum library. The ABI is used to interact with the Uniswap v3 pool contract. 14 | 15 | ```bash 16 | abigen --sol ./contracts/UniswapV3Pool.sol --pkg uniswap --out ./uniswap_v3_pool.go 17 | ``` 18 | -------------------------------------------------------------------------------- /providers/apis/defi/uniswapv3/math.go: -------------------------------------------------------------------------------- 1 | package uniswapv3 2 | 3 | import ( 4 | "math/big" 5 | 6 | "github.com/skip-mev/connect/v2/pkg/math" 7 | ) 8 | 9 | // ConvertSquareRootX96Price converts the slot 0 sqrtPriceX96 value to a price. Note that this 10 | // price is not scaled to the token decimals. This calculation is equivalent to: 11 | // 12 | // price = (sqrtPriceX96 / 2^96) ^ 2. 13 | func ConvertSquareRootX96Price( 14 | sqrtPriceX96 *big.Int, 15 | ) *big.Float { 16 | // Convert the original sqrtPriceX96 to a big float to retain precision when dividing. 17 | sqrtPriceX96Float := new(big.Float).SetInt(sqrtPriceX96) 18 | 19 | // x96Float is the fixed-point precision for Uniswap V3 prices. This is equal to 2^96. 20 | x96Float := new(big.Float).SetInt( 21 | new(big.Int).Exp(big.NewInt(2), big.NewInt(96), nil), 22 | ) 23 | 24 | // Divide the sqrtPriceX96 by the fixed-point precision. 25 | sqrtPriceFloat := new(big.Float).Quo(sqrtPriceX96Float, x96Float) 26 | 27 | // Square the price to get the final result. 28 | return new(big.Float).Mul(sqrtPriceFloat, sqrtPriceFloat) 29 | } 30 | 31 | // ScalePrice scales the price to the desired ticker decimals. The price is normalized to 32 | // the token decimals in the erc20 token contracts. 33 | func ScalePrice( 34 | cfg PoolConfig, 35 | price *big.Float, 36 | ) *big.Float { 37 | // Adjust the price based on the difference between the token decimals in the erc20 token contracts. 38 | erc20ScalingFactor := math.GetScalingFactor( 39 | cfg.BaseDecimals, 40 | cfg.QuoteDecimals, 41 | ) 42 | 43 | // Invert the price if the configuration specifies to do so. 44 | if cfg.Invert { 45 | scaledERC20AdjustedPrice := new(big.Float).Quo(price, erc20ScalingFactor) 46 | return new(big.Float).Quo(big.NewFloat(1), scaledERC20AdjustedPrice) 47 | } 48 | return new(big.Float).Mul(price, erc20ScalingFactor) 49 | } 50 | -------------------------------------------------------------------------------- /providers/apis/dydx/types/exchange_config_json.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // ExchangeConfigJson demarshals the exchange configuration json for a particular market. 4 | // The result is a list of parameters that define how the market is resolved on 5 | // each supported exchange. 6 | // 7 | // This struct stores data in an intermediate form as it's being assigned to various 8 | // `ExchangeMarketConfig` objects, which are keyed by exchange id. These objects are not kept 9 | // past the time the `GetAllMarketParams` API response is parsed, and do not contain an id 10 | // because the id is expected to be known at the time the object is in use. 11 | type ExchangeConfigJson struct { //nolint 12 | Exchanges []ExchangeMarketConfigJson `json:"exchanges"` 13 | } 14 | 15 | // ExchangeMarketConfigJson captures per-exchange information for resolving a market, including 16 | // the ticker and conversion details. It demarshals JSON parameters from the chain for a 17 | // particular market on a specific exchange. 18 | type ExchangeMarketConfigJson struct { //nolint 19 | ExchangeName string `json:"exchangeName"` 20 | Ticker string `json:"ticker"` 21 | AdjustByMarket string `json:"adjustByMarket,omitempty"` 22 | Invert bool `json:"invert,omitempty"` 23 | } 24 | -------------------------------------------------------------------------------- /providers/apis/dydx/types/query.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // QueryAllMarketParamsResponse is response type for the Query/Params 4 | // `AllMarketParams` RPC method. 5 | type QueryAllMarketParamsResponse struct { 6 | MarketParams []MarketParam `protobuf:"bytes,1,rep,name=market_params,json=marketParams,proto3" json:"market_params"` 7 | } 8 | 9 | // MarketParam represents the x/prices configuration for markets, including 10 | // representing price values, resolving markets on individual exchanges, and 11 | // generating price updates. This configuration is specific to the quote 12 | // currency. 13 | type MarketParam struct { 14 | // Unique, sequentially-generated value. 15 | Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` //nolint 16 | // The human-readable name of the market pair (e.g. `BTC-USD`). 17 | Pair string `protobuf:"bytes,2,opt,name=pair,proto3" json:"pair,omitempty"` 18 | // Static value. The exponent of the price. 19 | // For example if `Exponent == -5` then a `Value` of `1,000,000,000` 20 | // represents “$10,000`. Therefore `10 ^ Exponent` represents the smallest 21 | // price step (in dollars) that can be recorded. 22 | Exponent int32 `protobuf:"zigzag32,3,opt,name=exponent,proto3" json:"exponent,omitempty"` 23 | // The minimum number of exchanges that should be reporting a live price for 24 | // a price update to be considered valid. 25 | MinExchanges uint32 `protobuf:"varint,4,opt,name=min_exchanges,json=minExchanges,proto3" json:"min_exchanges,omitempty"` 26 | // The minimum allowable change in `price` value that would cause a price 27 | // update on the network. Measured as `1e-6` (parts per million). 28 | MinPriceChangePpm uint32 `protobuf:"varint,5,opt,name=min_price_change_ppm,json=minPriceChangePpm,proto3" json:"min_price_change_ppm,omitempty"` 29 | // A string of json that encodes the configuration for resolving the price 30 | // of this market on various exchanges. 31 | ExchangeConfigJson string `protobuf:"bytes,6,opt,name=exchange_config_json,json=exchangeConfigJson,proto3" json:"exchange_config_json,omitempty"` //nolint 32 | } 33 | -------------------------------------------------------------------------------- /providers/apis/dydx/types/research_json.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // MarketParamIndex is the index into the research json market-structure under which 4 | // the market's parameters are stored. 5 | const MarketParamIndex = "params" 6 | 7 | type ResearchJSONMarketParam struct { 8 | // Id is the unique identifier for the market 9 | ID uint32 `json:"id"` 10 | 11 | // Pair is the ticker symbol for the market 12 | Pair string `json:"ticker"` 13 | 14 | // Exponent is the number of decimal places to shift the price by 15 | Exponent float64 `json:"priceExponent"` 16 | 17 | // MinExchanges is the minimum number of exchanges that must provide data for the market 18 | MinExchanges uint32 `json:"minExchanges"` 19 | 20 | // MinPriceChangePpm is the minimum price change that must be observed for the market 21 | MinPriceChangePpm uint32 `json:"minPriceChange"` 22 | 23 | // ExchangeConfigJSON is the json object that contains the exchange configuration for the market 24 | ExchangeConfigJSON []ExchangeMarketConfigJson `json:"exchangeConfigJson"` 25 | } 26 | 27 | // ResearchJSON is the go-struct that encompasses the dydx research json, as hosted 28 | // on [github](https://raw.githubusercontent.com/dydxprotocol/v4-web/main/public/configs/otherMarketData.json) 29 | type ResearchJSON map[string]Params 30 | 31 | type Params struct { 32 | ResearchJSONMarketParam `json:"params"` 33 | MetaData `json:"meta"` 34 | } 35 | 36 | type MetaData struct { 37 | CMCID int `json:"cmcId"` 38 | } 39 | -------------------------------------------------------------------------------- /providers/apis/geckoterminal/README.md: -------------------------------------------------------------------------------- 1 | # GeckoTerminal Provider 2 | 3 | ## Overview 4 | 5 | The GeckoTerminal provider is used to fetch the spot price for tokens on a variety of blockchains, pools, decentralized exchanges, and other sources. This provider can be configured to fetch with or without an API key. Note that without an API key, it is very likely that the GeckoTerminal API will rate limit your requests (30 requests per minute). 6 | 7 | To read more about the GeckoTerminal API, visit the [GeckoTerminal API documentation](https://apiguide.geckoterminal.com/getting-started). 8 | -------------------------------------------------------------------------------- /providers/apis/kraken/README.md: -------------------------------------------------------------------------------- 1 | # Kraken Provider 2 | 3 | ## Kraken 4 | 5 | The Kraken provider is used to fetch the spot price for cryptocurrencies from the [Kraken API](https://docs.kraken.com/rest/#tag/Spot-Market-Data/operation/getTickerInformation). 6 | 7 | ## Supported Pairs 8 | 9 | To determine the pairs (in the form `BASEQUOTE`) currencies that the Binance provider supports, you can run the following command: 10 | 11 | ```bash 12 | $ curl "https://api.kraken.com/0/public/Ticker 13 | ``` 14 | -------------------------------------------------------------------------------- /providers/apis/marketmap/utils.go: -------------------------------------------------------------------------------- 1 | package marketmap 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | const ( 10 | // Name is the name of the MarketMap provider. 11 | Name = "marketmap_api" 12 | ) 13 | 14 | // DefaultAPIConfig returns the default configuration for the MarketMap API. 15 | var DefaultAPIConfig = config.APIConfig{ 16 | Name: Name, 17 | Atomic: true, 18 | Enabled: true, 19 | Timeout: 20 * time.Second, 20 | Interval: 10 * time.Second, 21 | ReconnectTimeout: 2000 * time.Millisecond, 22 | MaxQueries: 1, 23 | Endpoints: []config.Endpoint{{URL: "localhost:9090"}}, 24 | } 25 | -------------------------------------------------------------------------------- /providers/apis/polymarket/config.go: -------------------------------------------------------------------------------- 1 | package polymarket 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | var DefaultAPIConfig = config.APIConfig{ 10 | Name: Name, 11 | Atomic: false, 12 | Enabled: true, 13 | Timeout: 3 * time.Second, 14 | Interval: 500 * time.Millisecond, 15 | ReconnectTimeout: 2 * time.Second, 16 | MaxQueries: 1, 17 | Endpoints: []config.Endpoint{{URL: URL}}, 18 | } 19 | -------------------------------------------------------------------------------- /providers/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skip-mev/connect/9f85605735ddea817886fe8a26d309d761d242fe/providers/architecture.png -------------------------------------------------------------------------------- /providers/base/api/handlers/api_data_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | providertypes "github.com/skip-mev/connect/v2/providers/types" 7 | ) 8 | 9 | // APIDataHandler defines an interface that must be implemented by all providers that 10 | // want to fetch data from an API using HTTP requests. This interface is meant to be 11 | // paired with the APIQueryHandler. The APIQueryHandler will use the APIDataHandler to 12 | // create the URL to be sent to the HTTP client and parse the response from the client. 13 | // 14 | //go:generate mockery --name APIDataHandler --output ./mocks/ --case underscore 15 | type APIDataHandler[K providertypes.ResponseKey, V providertypes.ResponseValue] interface { 16 | // CreateURL is used to create the URL to be sent to the http client. The function 17 | // should utilize the IDs passed in as references to the data that needs to be fetched. 18 | CreateURL(ids []K) (string, error) 19 | 20 | // ParseResponse is used to parse the response from the client. The response should be 21 | // parsed into a map of IDs to results. If any IDs are not resolved, they should 22 | // be returned in the unresolved map. The timestamp associated with the result should 23 | // reflect either the time the data was fetched or the time the API last updated the data. 24 | ParseResponse(ids []K, response *http.Response) providertypes.GetResponse[K, V] 25 | } 26 | -------------------------------------------------------------------------------- /providers/base/api/handlers/mocks/query_handler.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.30.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | 10 | types "github.com/skip-mev/connect/v2/providers/types" 11 | ) 12 | 13 | // QueryHandler is an autogenerated mock type for the QueryHandler type 14 | type QueryHandler[K types.ResponseKey, V types.ResponseValue] struct { 15 | mock.Mock 16 | } 17 | 18 | // Query provides a mock function with given fields: ctx, ids, responseCh 19 | func (_m *QueryHandler[K, V]) Query(ctx context.Context, ids []K, responseCh chan<- types.GetResponse[K, V]) { 20 | _m.Called(ctx, ids, responseCh) 21 | } 22 | 23 | // NewQueryHandler creates a new instance of QueryHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 24 | // The first argument is typically a *testing.T value. 25 | func NewQueryHandler[K types.ResponseKey, V types.ResponseValue](t interface { 26 | mock.TestingT 27 | Cleanup(func()) 28 | }, 29 | ) *QueryHandler[K, V] { 30 | mock := &QueryHandler[K, V]{} 31 | mock.Mock.Test(t) 32 | 33 | t.Cleanup(func() { mock.AssertExpectations(t) }) 34 | 35 | return mock 36 | } 37 | -------------------------------------------------------------------------------- /providers/base/api/handlers/options.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | // Option is a function that is used to configure a RequestHandler. 4 | type Option func(*RequestHandlerImpl) 5 | 6 | // WithHTTPMethod is an option that is used to set the HTTP method used to make requests. 7 | func WithHTTPMethod(method string) Option { 8 | return func(r *RequestHandlerImpl) { 9 | r.method = method 10 | } 11 | } 12 | 13 | // WithHTTPHeaders is an option that is used to set the HTTP headers used to make requests. 14 | func WithHTTPHeaders(headers map[string]string) Option { 15 | return func(r *RequestHandlerImpl) { 16 | r.headers = headers 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /providers/base/api/metrics/constants.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "fmt" 4 | 5 | const ( 6 | // StatusLabel is a label for the status of a provider API response. 7 | StatusLabel = "internal_status" 8 | // StatusCodeLabel is a label for the status code of a provider API response. 9 | StatusCodeLabel = "status_code" 10 | // StatusCodeExactLabel is a label for the exact status code of a provider API response. 11 | StatusCodeExactLabel = "status_code_exact" 12 | // EndpointLabel is a label for the endpoint of a provider API response. 13 | EndpointLabel = "endpoint" 14 | // RedactedURL is a label for the redacted URL of a provider API response. 15 | RedactedURL = "redacted_url" 16 | ) 17 | 18 | type ( 19 | // RPCCode is the status code a RPC request. 20 | RPCCode string 21 | ) 22 | 23 | const ( 24 | // RPCCodeOK is the status code for a successful RPC request. 25 | RPCCodeOK RPCCode = "ok" 26 | // RPCCodeError is the status code for a failed RPC request. 27 | RPCCodeError RPCCode = "request_error" 28 | ) 29 | 30 | // RedactedEndpointURL returns a redacted version of the given URL. 31 | func RedactedEndpointURL(index int) string { 32 | return fmt.Sprintf("redacted_endpoint_index=%d", index) 33 | } 34 | -------------------------------------------------------------------------------- /providers/base/utils.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | providertypes "github.com/skip-mev/connect/v2/providers/types" 8 | ) 9 | 10 | // createResponseCh creates the response channel for the provider. 11 | func (p *Provider[K, V]) createResponseCh() error { 12 | // responseCh is used to receive the response(s) from the query handler. 13 | switch { 14 | case p.Type() == providertypes.API: 15 | // If the provider is an API provider, then the buffer size is set to the number of IDs. 16 | p.responseCh = make(chan providertypes.GetResponse[K, V], len(p.GetIDs())) 17 | case p.Type() == providertypes.WebSockets: 18 | // Otherwise, the buffer size is set to the max buffer size configured for the websocket. 19 | p.responseCh = make(chan providertypes.GetResponse[K, V], p.wsCfg.MaxBufferSize) 20 | default: 21 | return fmt.Errorf("no api or websocket configured") 22 | } 23 | 24 | return nil 25 | } 26 | 27 | // setMainCtx sets the main context for the provider. 28 | func (p *Provider[K, V]) setMainCtx(ctx context.Context) (context.Context, context.CancelFunc) { 29 | p.mu.Lock() 30 | defer p.mu.Unlock() 31 | 32 | p.mainCtx, p.cancelMainFn = context.WithCancel(ctx) 33 | return p.mainCtx, p.cancelMainFn 34 | } 35 | 36 | // getMainCtx returns the main context for the provider. 37 | func (p *Provider[K, V]) getMainCtx() (context.Context, context.CancelFunc) { 38 | p.mu.Lock() 39 | defer p.mu.Unlock() 40 | 41 | return p.mainCtx, p.cancelMainFn 42 | } 43 | 44 | // setFetchCtx sets the fetch context for the provider. 45 | func (p *Provider[K, V]) setFetchCtx(ctx context.Context) (context.Context, context.CancelFunc) { 46 | p.mu.Lock() 47 | defer p.mu.Unlock() 48 | 49 | p.fetchCtx, p.cancelFetchFn = context.WithCancel(ctx) 50 | return p.fetchCtx, p.cancelFetchFn 51 | } 52 | 53 | // getFetchCtx returns the fetch context for the provider. 54 | func (p *Provider[K, V]) getFetchCtx() (context.Context, context.CancelFunc) { 55 | p.mu.Lock() 56 | defer p.mu.Unlock() 57 | 58 | return p.fetchCtx, p.cancelFetchFn 59 | } 60 | -------------------------------------------------------------------------------- /providers/base/websocket/handlers/options.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | // Option is a function that is used to configure a WebSocketConnHandler. 4 | type Option func(*WebSocketConnHandlerImpl) 5 | 6 | // WithPreDialHook is an option that is used to set a pre-dial hook for a websocket connection. 7 | func WithPreDialHook(hook PreDialHook) Option { 8 | return func(r *WebSocketConnHandlerImpl) { 9 | if hook == nil { 10 | panic("pre-dial hook cannot be nil") 11 | } 12 | 13 | r.preDialHook = hook 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /providers/base/websocket/handlers/ws_data_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | providertypes "github.com/skip-mev/connect/v2/providers/types" 5 | ) 6 | 7 | // WebSocketDataHandler defines an interface that must be implemented by all providers that 8 | // want to fetch data from a websocket. This interface is meant to be paired with the 9 | // WebSocketQueryHandler. The WebSocketQueryHandler will use the WebSocketDataHandler to 10 | // create establish a connection to the correct host, create subscription messages to be sent 11 | // to the data provider, and handle incoming events accordingly. 12 | // 13 | //go:generate mockery --name WebSocketDataHandler --output ./mocks/ --case underscore 14 | type WebSocketDataHandler[K providertypes.ResponseKey, V providertypes.ResponseValue] interface { 15 | // HandleMessage is used to handle a message received from the data provider. Message parsing 16 | // and response creation should be handled by this data handler. Given a message from the websocket 17 | // the handler should either return a response or a set of update messages. 18 | HandleMessage(message []byte) (response providertypes.GetResponse[K, V], updateMessages []WebsocketEncodedMessage, err error) 19 | 20 | // CreateMessages is used to update the connection to the data provider. This can be used to subscribe 21 | // to new events or unsubscribe from events. 22 | CreateMessages(ids []K) ([]WebsocketEncodedMessage, error) 23 | 24 | // HeartBeatMessages is used to construct heartbeat messages to be sent to the data provider. Note that 25 | // the handler must maintain the necessary state information to construct the heartbeat messages. This 26 | // can be done on the fly as messages as handled by the handler. 27 | HeartBeatMessages() ([]WebsocketEncodedMessage, error) 28 | 29 | // Copy is used to create a copy of the data handler. This is useful for creating multiple connections 30 | // to the same data provider. Stateful information can be managed independently for each connection. 31 | Copy() WebSocketDataHandler[K, V] 32 | } 33 | -------------------------------------------------------------------------------- /providers/factories/README.md: -------------------------------------------------------------------------------- 1 | # Factories 2 | 3 | ## Overview 4 | 5 | Factories are used to create an underlying set of data providers that will be utilized by the oracle sidecar. Currently, the factory is primarily built to support price feeds, but later will be extended to support other data types. 6 | 7 | ## Supported Provider Factories 8 | 9 | * **Price Feed Factory**: This factory is used to construct a set of API and Websocket oracle price feed providers that fetch price data from various sources. 10 | * **Market Map Factory**: This factory is used to construct a set of API oracle market providers that fetch market data from market map providers - providers that are responsible for determining the markets the oracle should be fetching prices for. 11 | -------------------------------------------------------------------------------- /providers/providertest/README.md: -------------------------------------------------------------------------------- 1 | # Provider testing 2 | 3 | ## Example 4 | 5 | The following example can be used as a base for testing providers. 6 | 7 | ```go 8 | package providertest_test 9 | 10 | import ( 11 | "context" 12 | "testing" 13 | 14 | "go.uber.org/zap" 15 | 16 | "github.com/stretchr/testify/require" 17 | 18 | connecttypes "github.com/skip-mev/connect/v2/pkg/types" 19 | "github.com/skip-mev/connect/v2/providers/providertest" 20 | mmtypes "github.com/skip-mev/connect/v2/x/marketmap/types" 21 | ) 22 | 23 | var ( 24 | usdtusd = mmtypes.Market{ 25 | Ticker: mmtypes.Ticker{ 26 | CurrencyPair: connecttypes.CurrencyPair{ 27 | Base: "USDT", 28 | Quote: "USD", 29 | }, 30 | Decimals: 8, 31 | MinProviderCount: 1, 32 | Enabled: true, 33 | }, 34 | ProviderConfigs: []mmtypes.ProviderConfig{ 35 | { 36 | Name: "okx_ws", 37 | OffChainTicker: "USDC-USDT", 38 | Invert: true, 39 | }, 40 | }, 41 | } 42 | 43 | mm = mmtypes.MarketMap{ 44 | Markets: map[string]mmtypes.Market{ 45 | usdtusd.Ticker.String(): usdtusd, 46 | }, 47 | } 48 | ) 49 | 50 | func TestProvider(t *testing.T) { 51 | // take in a market map and filter it to output N market maps with only a single provider 52 | marketsPerProvider := providertest.FilterMarketMapToProviders(mm) 53 | 54 | // run this check for each provider (here only okx_ws) 55 | for provider, marketMap := range marketsPerProvider { 56 | ctx := context.Background() 57 | p, err := providertest.NewTestingOracle(ctx, provider) 58 | require.NoError(t, err) 59 | 60 | results, err := p.RunMarketMap(ctx, marketMap, providertest.DefaultProviderTestConfig()) 61 | require.NoError(t, err) 62 | 63 | p.Logger.Info("results", zap.Any("results", results)) 64 | } 65 | } 66 | 67 | ``` 68 | -------------------------------------------------------------------------------- /providers/static/api_handler.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "math/big" 5 | "net/http" 6 | "time" 7 | 8 | providertypes "github.com/skip-mev/connect/v2/providers/types" 9 | 10 | "github.com/skip-mev/connect/v2/oracle/types" 11 | ) 12 | 13 | var _ types.PriceAPIDataHandler = (*MockAPIHandler)(nil) 14 | 15 | const ( 16 | // Name is the name of the provider. 17 | Name = "static-mock-provider" 18 | ) 19 | 20 | // MockAPIHandler implements a mock API handler that returns static data. 21 | type MockAPIHandler struct{} 22 | 23 | // NewAPIHandler returns a new MockAPIHandler. This constructs a new static mock provider from 24 | // the config. Notice this method expects the market configuration map to the offchain ticker 25 | // to the desired price. 26 | func NewAPIHandler() types.PriceAPIDataHandler { 27 | return &MockAPIHandler{} 28 | } 29 | 30 | // CreateURL is a no-op. 31 | func (s *MockAPIHandler) CreateURL(_ []types.ProviderTicker) (string, error) { 32 | return "static-url", nil 33 | } 34 | 35 | // ParseResponse is a no-op. This simply returns the price of the tickers configured, 36 | // timestamped with the current time. 37 | func (s *MockAPIHandler) ParseResponse( 38 | tickers []types.ProviderTicker, 39 | _ *http.Response, 40 | ) types.PriceResponse { 41 | var ( 42 | resolved = make(types.ResolvedPrices) 43 | unresolved = make(types.UnResolvedPrices) 44 | ) 45 | 46 | for _, ticker := range tickers { 47 | var metaData MetaData 48 | if err := metaData.FromJSON(ticker.GetJSON()); err == nil { 49 | resolved[ticker] = types.NewPriceResult( 50 | big.NewFloat(metaData.Price), 51 | time.Now().UTC(), 52 | ) 53 | } else { 54 | unresolved[ticker] = providertypes.UnresolvedResult{ 55 | ErrorWithCode: providertypes.NewErrorWithCode( 56 | err, 57 | providertypes.ErrorFailedToParsePrice, 58 | ), 59 | } 60 | } 61 | } 62 | 63 | return types.NewPriceResponse(resolved, unresolved) 64 | } 65 | -------------------------------------------------------------------------------- /providers/static/client.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/skip-mev/connect/v2/providers/base/api/handlers" 10 | ) 11 | 12 | var _ handlers.RequestHandler = (*MockClient)(nil) 13 | 14 | // MockClient is meant to be paired with the MockAPIHandler. It 15 | // should only be used for testing. 16 | type MockClient struct{} 17 | 18 | func NewStaticMockClient() *MockClient { 19 | return &MockClient{} 20 | } 21 | 22 | // Do is a no-op. 23 | func (s *MockClient) Do(_ context.Context, _ string) (*http.Response, error) { 24 | return &http.Response{ 25 | StatusCode: http.StatusOK, 26 | Body: io.NopCloser(strings.NewReader(`{"result": "success"}`)), 27 | }, nil 28 | } 29 | 30 | // Type returns the HTTP method used to send requests. 31 | func (s *MockClient) Type() string { 32 | return http.MethodGet 33 | } 34 | -------------------------------------------------------------------------------- /providers/static/utils.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // MetaData is the per-ticker specific metadata that is used to configure the static provider. 9 | type MetaData struct { 10 | Price float64 `json:"price"` 11 | } 12 | 13 | // FromJSON unmarshals the JSON data into a MetaData struct. 14 | func (m *MetaData) FromJSON(jsonStr string) error { 15 | err := json.Unmarshal([]byte(jsonStr), m) 16 | return err 17 | } 18 | 19 | // MustToJSON marshals the MetaData struct into a JSON string. 20 | func (m *MetaData) MustToJSON() string { 21 | bz, err := json.Marshal(m) 22 | if err != nil { 23 | panic(fmt.Errorf("failed to marshal metadata: %w", err)) 24 | } 25 | return string(bz) 26 | } 27 | -------------------------------------------------------------------------------- /providers/types/provider.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "golang.org/x/net/context" 5 | ) 6 | 7 | type ProviderType string 8 | 9 | const ( 10 | WebSockets ProviderType = "websockets" 11 | API ProviderType = "api" 12 | ) 13 | 14 | // Provider defines an interface a data provider must implement. 15 | // 16 | //go:generate mockery --name Provider --filename mock_provider.go 17 | type Provider[K ResponseKey, V ResponseValue] interface { 18 | // Name returns the name of the provider. 19 | Name() string 20 | 21 | // GetData returns the aggregated data for the given (key, value) pairs. 22 | // For example, if the provider is fetching prices for a set of currency 23 | // pairs, the data returned by this function would be the latest prices 24 | // for those currency pairs. 25 | GetData() map[K]ResolvedResult[V] 26 | 27 | // Start starts the provider. 28 | Start(context.Context) error 29 | 30 | // Type returns the type of the provider data handler. 31 | Type() ProviderType 32 | 33 | // IsRunning returns whether the provider is running. 34 | IsRunning() bool 35 | } 36 | -------------------------------------------------------------------------------- /providers/volatile/api_handler_test.go: -------------------------------------------------------------------------------- 1 | package volatile_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/oracle/types" 9 | "github.com/skip-mev/connect/v2/providers/volatile" 10 | ) 11 | 12 | var ( 13 | ticker1 = types.NewProviderTicker("foo/bar", "{}") 14 | ticker2 = types.NewProviderTicker("foo/baz", "{}") 15 | ) 16 | 17 | func setupTest(t *testing.T) types.PriceAPIDataHandler { 18 | t.Helper() 19 | h := volatile.NewAPIHandler() 20 | return h 21 | } 22 | 23 | func TestCreateURL(t *testing.T) { 24 | volatileHandler := setupTest(t) 25 | url, err := volatileHandler.CreateURL(nil) 26 | require.NoError(t, err) 27 | require.Equal(t, "volatile-exchange-url", url) 28 | } 29 | 30 | func TestParseResponse(t *testing.T) { 31 | volatileHandler := setupTest(t) 32 | resp := volatileHandler.ParseResponse([]types.ProviderTicker{ticker1, ticker2}, nil) 33 | require.Equal(t, 2, len(resp.Resolved)) 34 | require.NotNilf(t, resp.Resolved[ticker1], "did not receive a response for ticker1") 35 | } 36 | -------------------------------------------------------------------------------- /providers/volatile/price.go: -------------------------------------------------------------------------------- 1 | package volatile 2 | 3 | import ( 4 | "math" 5 | "math/big" 6 | "time" 7 | ) 8 | 9 | type TimeProvider func() time.Time 10 | 11 | var ( 12 | // dailySeconds is the number of seconds in a day. 13 | dailySeconds = float64(24 * 60 * 60) 14 | // normalizedPhaseSize is the radians in our repeating function (4π) before adjusting for frequency and time. 15 | normalizedPhaseSize = float64(4) 16 | ) 17 | 18 | // GetVolatilePrice generates a time-based price value. The value follows a cosine wave 19 | // function, but that includes jumps from the lowest value to the highest value (and vice versa) 20 | // once per period. The general formula is written below. 21 | // - price = offset * (1 + amplitude * cosVal) 22 | // - cosVal = math.Cos(radians) 23 | // - radians = (cosinePhase <= 0.5 ? cosinePhase * 4 : cosinePhase * 4 - 1) * π 24 | // - cosinePhase = (frequency * unix_time(in seconds) / dailySeconds) % 1. 25 | func GetVolatilePrice(tp TimeProvider, amplitude float64, offset float64, frequency float64) *big.Float { 26 | // The phase is the location of the final price within our repeating price function. 27 | // The resulting value is taken mod(1) i.e. it is between 0 and 1 inclusive 28 | cosinePhase := math.Mod( 29 | frequency*float64(tp().Unix())/dailySeconds, 30 | 1, 31 | ) 32 | // To achieve our price "jump", we implement a piecewise function at 0.5 33 | radians := cosinePhase * normalizedPhaseSize 34 | if cosinePhase > 0.5 { 35 | radians -= float64(1) 36 | } 37 | radians *= math.Pi 38 | cosVal := math.Cos(radians) 39 | return big.NewFloat(offset * (1 + amplitude*cosVal)) 40 | } 41 | -------------------------------------------------------------------------------- /providers/websockets/binance/README.md: -------------------------------------------------------------------------------- 1 | # Binance Provider 2 | 3 | ## Overview 4 | 5 | The Binance provider is used to fetch the ticker price from the [Binance websocket API](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams). A single connection is only valid for 24 hours; after that, a new connection must be established. The Websocket server will send a ping frame every 3 minutes. If the client does not receive a pong frame within 10 minutes, the connection will be closed. Note that all symbols are in lowercase. 6 | 7 | The WebSocket connection has a limit of 5 incoming messages per second. A message is considered: 8 | 9 | * A Ping frame 10 | * A Pong frame 11 | * A JSON controlled message (e.g. a subscription) 12 | * A connection that goes beyond the rate limit will be disconnected. IPs that are repeatedly disconnected for going beyond the rate limit may be banned for a period of time. 13 | 14 | A single connection can listen to a maximum of 1024 streams. If a user attempts to listen to more streams, the connection will be disconnected. There is a limit of 300 connections per attempt every 5 minutes per IP. 15 | 16 | The specific channels / streams that are subscribed to is the [Aggregate Trade Stream](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#aggregate-trade-streams) and the [Ticker Stream](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#aggregate-trade-streams). The Aggregate Trade Streams push trade information that is aggregated for a single taker order in real time. The ticker stream pushes the ticker spot price every second. 17 | -------------------------------------------------------------------------------- /providers/websockets/binance/parse.go: -------------------------------------------------------------------------------- 1 | package binance 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | providertypes "github.com/skip-mev/connect/v2/providers/types" 8 | 9 | "github.com/skip-mev/connect/v2/oracle/types" 10 | "github.com/skip-mev/connect/v2/pkg/math" 11 | ) 12 | 13 | // parsePriceUpdateMessage parses a price update message from the Binance websocket feed. 14 | // This is repurposed for ticker and aggregate trade messages. 15 | func (h *WebSocketHandler) parsePriceUpdateMessage(offChainTicker string, price string) (types.PriceResponse, error) { 16 | var ( 17 | resolved = make(types.ResolvedPrices) 18 | unResolved = make(types.UnResolvedPrices) 19 | ) 20 | 21 | ticker, ok := h.cache.FromOffChainTicker(offChainTicker) 22 | if !ok { 23 | return types.NewPriceResponse(resolved, unResolved), 24 | fmt.Errorf("got response for an unsupported market %s", offChainTicker) 25 | } 26 | 27 | // Convert the price to a big Float. 28 | priceFloat, err := math.Float64StringToBigFloat(price) 29 | if err != nil { 30 | unResolved[ticker] = providertypes.UnresolvedResult{ 31 | ErrorWithCode: providertypes.NewErrorWithCode(err, providertypes.ErrorFailedToParsePrice), 32 | } 33 | return types.NewPriceResponse(resolved, unResolved), err 34 | } 35 | 36 | resolved[ticker] = types.NewPriceResult(priceFloat, time.Now().UTC()) 37 | return types.NewPriceResponse(resolved, unResolved), nil 38 | } 39 | -------------------------------------------------------------------------------- /providers/websockets/bitfinex/README.md: -------------------------------------------------------------------------------- 1 | # BitFinex Provider 2 | 3 | ## Overview 4 | 5 | The BitFinex provider is used to fetch the ticker price from the [BitFinex websocket API](https://docs.bitfinex.com/docs/ws-general). The total amount of subscriptions per connection is [30](https://docs.bitfinex.com/docs/ws-general#how-to-connect). 6 | 7 | BitFinex provides [public](https://docs.bitfinex.com/docs/ws-public) and [private (authenticated)](https://docs.bitfinex.com/docs/ws-auth) channels. 8 | 9 | * Public channels -- No authentication is required, include tickers channel, K-Line channel, limit price channel, order book channel, and mark price channel etc. 10 | * Private channels -- including account channel, order channel, and position channel, etc -- require log in. 11 | 12 | The exact channel that is used to subscribe to the ticker price is the [`Tickers`](https://docs.bitfinex.com/reference/ws-public-ticker). This pushes data regularly regarding a ticker status. 13 | 14 | To retrieve all supported [tickers](https://docs.bitfinex.com/reference/rest-public-tickers), please run the following command: 15 | 16 | ```bash 17 | curl https://api-pub.bitfinex.com/v2/conf/pub:list:currency 18 | ``` 19 | -------------------------------------------------------------------------------- /providers/websockets/bitfinex/utils.go: -------------------------------------------------------------------------------- 1 | package bitfinex 2 | 3 | import ( 4 | "github.com/skip-mev/connect/v2/oracle/config" 5 | ) 6 | 7 | const ( 8 | // Name is the name of the BitFinex provider. 9 | Name = "bitfinex_ws" 10 | 11 | // URLProd is the public BitFinex Websocket URL. 12 | URLProd = "wss://api-pub.bitfinex.com/ws/2" 13 | 14 | // DefaultMaxSubscriptionsPerConnection is the default maximum number of subscriptions 15 | // per connection. By default, BitFinex accepts up to 30 subscriptions per connection. 16 | // However, we limit this to 20 to prevent overloading the connection. 17 | DefaultMaxSubscriptionsPerConnection = 20 18 | ) 19 | 20 | // DefaultWebSocketConfig is the default configuration for the BitFinex Websocket. 21 | var DefaultWebSocketConfig = config.WebSocketConfig{ 22 | Name: Name, 23 | Enabled: true, 24 | MaxBufferSize: 1000, 25 | ReconnectionTimeout: config.DefaultReconnectionTimeout, 26 | PostConnectionTimeout: config.DefaultPostConnectionTimeout, 27 | Endpoints: []config.Endpoint{{URL: URLProd}}, 28 | ReadBufferSize: config.DefaultReadBufferSize, 29 | WriteBufferSize: config.DefaultWriteBufferSize, 30 | HandshakeTimeout: config.DefaultHandshakeTimeout, 31 | EnableCompression: config.DefaultEnableCompression, 32 | ReadTimeout: config.DefaultReadTimeout, 33 | WriteTimeout: config.DefaultWriteTimeout, 34 | PingInterval: config.DefaultPingInterval, 35 | WriteInterval: config.DefaultWriteInterval, 36 | MaxReadErrorCount: config.DefaultMaxReadErrorCount, 37 | MaxSubscriptionsPerConnection: DefaultMaxSubscriptionsPerConnection, 38 | // Note that BitFinex does not support batch subscriptions. As such each new 39 | // market will be subscribed to with a new message. 40 | MaxSubscriptionsPerBatch: config.DefaultMaxSubscriptionsPerBatch, 41 | } 42 | -------------------------------------------------------------------------------- /providers/websockets/bitstamp/README.md: -------------------------------------------------------------------------------- 1 | # Bitstamp Provider 2 | 3 | ## Overview 4 | 5 | Bitstamp is a cryptocurrency exchange that provides a free API for fetching cryptocurrency data. Bitstamp is a **primary data source** for the oracle. It supports connecting to a websocket without authentication. Once you open a connection via websocket handshake (using HTTP upgrade header), you can subscribe to desired channels. After this is accomplished, you will start to receive a stream of live events for every channel you are subscribed to. Maximum connection age is 90 days from the time the connection is established. When that period of time elapses, you will be automatically disconnected and will need to re-connect. 6 | 7 | To see the supported set of markets, you can reference the websocket documentation [here](https://www.bitstamp.net/websocket/v2/). 8 | -------------------------------------------------------------------------------- /providers/websockets/bitstamp/parse.go: -------------------------------------------------------------------------------- 1 | package bitstamp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | providertypes "github.com/skip-mev/connect/v2/providers/types" 9 | 10 | "github.com/skip-mev/connect/v2/oracle/types" 11 | "github.com/skip-mev/connect/v2/pkg/math" 12 | ) 13 | 14 | // parseTickerMessage parses a ticker message received from the Bitstamp websocket API. 15 | // All price updates must be made from the live trades channel. 16 | func (h *WebSocketHandler) parseTickerMessage( 17 | msg TickerResponseMessage, 18 | ) (types.PriceResponse, error) { 19 | var ( 20 | resolved = make(types.ResolvedPrices) 21 | unResolved = make(types.UnResolvedPrices) 22 | ) 23 | 24 | // Ensure that the price feeds are coming from the live trading channel. 25 | if !strings.HasPrefix(msg.Channel, string(TickerChannel)) { 26 | return types.NewPriceResponse(resolved, unResolved), 27 | fmt.Errorf("invalid ticker message %s", msg.Channel) 28 | } 29 | 30 | tickerSplit := strings.Split(msg.Channel, string(TickerChannel)) 31 | if len(tickerSplit) != ExpectedTickerLength { 32 | return types.NewPriceResponse(resolved, unResolved), 33 | fmt.Errorf("invalid ticker message length %s", msg.Channel) 34 | } 35 | 36 | // Get the ticker from the message and market. 37 | offChainTicker := tickerSplit[TickerCurrencyPairIndex] 38 | ticker, ok := h.cache.FromOffChainTicker(offChainTicker) 39 | if !ok { 40 | return types.NewPriceResponse(resolved, unResolved), 41 | fmt.Errorf("received unsupported ticker %s", ticker) 42 | } 43 | 44 | // Get the price from the message. 45 | price, err := math.Float64StringToBigFloat(msg.Data.PriceStr) 46 | if err != nil { 47 | unResolved[ticker] = providertypes.UnresolvedResult{ 48 | ErrorWithCode: providertypes.NewErrorWithCode(err, providertypes.ErrorFailedToParsePrice), 49 | } 50 | return types.NewPriceResponse(resolved, unResolved), err 51 | } 52 | 53 | resolved[ticker] = types.NewPriceResult(price, time.Now().UTC()) 54 | return types.NewPriceResponse(resolved, unResolved), nil 55 | } 56 | -------------------------------------------------------------------------------- /providers/websockets/bitstamp/utils.go: -------------------------------------------------------------------------------- 1 | package bitstamp 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | const ( 10 | // Name is the name of the bitstamp provider. 11 | Name = "bitstamp_ws" 12 | 13 | // WSS is the bitstamp websocket address. 14 | WSS = "wss://ws.bitstamp.net" 15 | 16 | // DefaultPingInterval is the default ping interval for the bitstamp websocket. 17 | DefaultPingInterval = 10 * time.Second 18 | ) 19 | 20 | // DefaultWebSocketConfig returns the default websocket config for bitstamp. 21 | var DefaultWebSocketConfig = config.WebSocketConfig{ 22 | Enabled: true, 23 | Name: Name, 24 | MaxBufferSize: config.DefaultMaxBufferSize, 25 | ReconnectionTimeout: config.DefaultReconnectionTimeout, 26 | PostConnectionTimeout: config.DefaultPostConnectionTimeout, 27 | Endpoints: []config.Endpoint{{URL: WSS}}, 28 | ReadBufferSize: config.DefaultReadBufferSize, 29 | WriteBufferSize: config.DefaultWriteBufferSize, 30 | HandshakeTimeout: config.DefaultHandshakeTimeout, 31 | EnableCompression: config.DefaultEnableCompression, 32 | WriteTimeout: config.DefaultWriteTimeout, 33 | ReadTimeout: config.DefaultReadTimeout, 34 | PingInterval: DefaultPingInterval, 35 | WriteInterval: config.DefaultWriteInterval, 36 | MaxReadErrorCount: config.DefaultMaxReadErrorCount, 37 | MaxSubscriptionsPerConnection: config.DefaultMaxSubscriptionsPerConnection, 38 | // Note that BitStamp does not support batch subscriptions. As such each new 39 | // market will be subscribed to with a new message. 40 | MaxSubscriptionsPerBatch: config.DefaultMaxSubscriptionsPerBatch, 41 | } 42 | -------------------------------------------------------------------------------- /providers/websockets/bybit/README.md: -------------------------------------------------------------------------------- 1 | # ByBit Provider 2 | 3 | ## Overview 4 | 5 | The ByBit provider is used to fetch the ticker price from the [ByBit websocket API](https://bybit-exchange.github.io/docs/v5/ws/connect). 6 | 7 | Connections may be disconnected if a heartbeat ping is not sent to the server every 20 seconds to maintain the connection. 8 | 9 | 10 | ByBit provides [public and private channels](https://bybit-exchange.github.io/docs/v5/ws/connect#how-to-subscribe-to-topics). 11 | 12 | * Public channels -- No authentication is required, include tickers topic, K-Line topic, limit price topic, order book topic, and mark price topic etc. 13 | * Private channels -- including account topic, order topic, and position topic, etc. -- require log in. 14 | 15 | Users can choose to subscribe to one or more topic, and the total length of multiple topics cannot exceed 21,000 characters. This provider is implemented assuming that the user is only subscribing to public topics. 16 | 17 | The exact topic that is used to subscribe to the ticker price is the [`Tickers`](https://bybit-exchange.github.io/docs/v5/websocket/public/ticker). This pushes data in real time if there are any price updates. 18 | 19 | To retrieve all supported [spot markets](https://bybit-exchange.github.io/docs/v5/market/instrument), please run the following command: 20 | 21 | ```bash 22 | curl "https://api.bybit.com/v5/market/instruments-info" 23 | ``` 24 | -------------------------------------------------------------------------------- /providers/websockets/bybit/utils.go: -------------------------------------------------------------------------------- 1 | package bybit 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | const ( 10 | // ByBit provides a few different URLs for its Websocket API. The URLs can be found 11 | // in the documentation here: https://bybit-exchange.github.io/docs/v5/ws/connect 12 | // The two production URLs are defined in ProductionURL and TestnetURL. 13 | 14 | // Name is the name of the ByBit provider. 15 | Name = "bybit_ws" 16 | 17 | // URLProd is the public ByBit Websocket URL. 18 | URLProd = "wss://stream.bybit.com/v5/public/spot" 19 | 20 | // URLTest is the public testnet ByBit Websocket URL. 21 | URLTest = "wss://stream-testnet.bybit.com/v5/public/spot" 22 | 23 | // DefaultPingInterval is the default ping interval for the ByBit websocket. 24 | DefaultPingInterval = 15 * time.Second 25 | ) 26 | 27 | // DefaultWebSocketConfig is the default configuration for the ByBit Websocket. 28 | var DefaultWebSocketConfig = config.WebSocketConfig{ 29 | Name: Name, 30 | Enabled: true, 31 | MaxBufferSize: 1000, 32 | ReconnectionTimeout: config.DefaultReconnectionTimeout, 33 | PostConnectionTimeout: config.DefaultPostConnectionTimeout, 34 | Endpoints: []config.Endpoint{{URL: URLProd}}, 35 | ReadBufferSize: config.DefaultReadBufferSize, 36 | WriteBufferSize: config.DefaultWriteBufferSize, 37 | HandshakeTimeout: config.DefaultHandshakeTimeout, 38 | EnableCompression: config.DefaultEnableCompression, 39 | ReadTimeout: config.DefaultReadTimeout, 40 | WriteTimeout: config.DefaultWriteTimeout, 41 | PingInterval: DefaultPingInterval, 42 | WriteInterval: config.DefaultWriteInterval, 43 | MaxReadErrorCount: config.DefaultMaxReadErrorCount, 44 | MaxSubscriptionsPerConnection: config.DefaultMaxSubscriptionsPerConnection, 45 | MaxSubscriptionsPerBatch: config.DefaultMaxSubscriptionsPerBatch, 46 | } 47 | -------------------------------------------------------------------------------- /providers/websockets/cryptodotcom/README.md: -------------------------------------------------------------------------------- 1 | # Crypto.com Provider 2 | 3 | ## Overview 4 | 5 | The Crypto.com provider is used to fetch the ticker price from the [Crypto.com websocket API](https://exchange-docs.crypto.com/exchange/v1/rest-ws/index.html?javascript#ticker-instrument_name). The websocket is [rate limited](https://exchange-docs.crypto.com/exchange/v1/rest-ws/index.html?javascript#rate-limits) with a maximum of 100 requests per second. This provider does not require any API keys. To determine the acceptable set of base and quote currencies, you can reference the [get instruments API](https://exchange-docs.crypto.com/exchange/v1/rest-ws/index.html?javascript#reference-and-market-data-api). 6 | 7 | To better distribute system load, a single market data websocket connection is limited to a maximum of 400 subscriptions. Once this limit is reached, further subscription requests will be rejected with the EXCEED_MAX_SUBSCRIPTIONS error code. A user should establish multiple connections if additional market data subscriptions are required. The names of the markets (BTCUSD-PERP vs. BTC_USD) represent the perpetual vs. spot markets. 8 | -------------------------------------------------------------------------------- /providers/websockets/cryptodotcom/parse.go: -------------------------------------------------------------------------------- 1 | package cryptodotcom 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | providertypes "github.com/skip-mev/connect/v2/providers/types" 8 | 9 | "go.uber.org/zap" 10 | 11 | "github.com/skip-mev/connect/v2/oracle/types" 12 | "github.com/skip-mev/connect/v2/pkg/math" 13 | ) 14 | 15 | // parseInstrumentMessage is used to parse an instrument message received from the Crypto.com 16 | // websocket API. This message contains the latest price data for a set of instruments. 17 | func (h *WebSocketHandler) parseInstrumentMessage( 18 | msg InstrumentResponseMessage, 19 | ) (types.PriceResponse, error) { 20 | var ( 21 | resolved = make(types.ResolvedPrices) 22 | unresolved = make(types.UnResolvedPrices) 23 | instruments = msg.Result.Data 24 | ) 25 | 26 | // If the response contained no instrument data, return an error. 27 | if len(instruments) == 0 { 28 | return types.NewPriceResponse(resolved, unresolved), 29 | fmt.Errorf("no instrument data was returned") 30 | } 31 | 32 | // Iterate through each market and attempt to parse the price. 33 | for _, instrument := range instruments { 34 | // If we don't have a mapping for the instrument, return an error. This is likely a configuration 35 | // error. 36 | ticker, ok := h.cache.FromOffChainTicker(instrument.Name) 37 | if !ok { 38 | h.logger.Debug("failed to find currency pair for instrument", zap.String("instrument", instrument.Name)) 39 | continue 40 | } 41 | 42 | // Attempt to parse the price. 43 | if price, err := math.Float64StringToBigFloat(instrument.LatestTradePrice); err != nil { 44 | wErr := fmt.Errorf("failed to parse price %s:"+" %w", instrument.LatestTradePrice, err) 45 | unresolved[ticker] = providertypes.UnresolvedResult{ 46 | ErrorWithCode: providertypes.NewErrorWithCode(wErr, providertypes.ErrorFailedToParsePrice), 47 | } 48 | } else { 49 | resolved[ticker] = types.NewPriceResult(price, time.Now().UTC()) 50 | } 51 | 52 | } 53 | 54 | return types.NewPriceResponse(resolved, unresolved), nil 55 | } 56 | -------------------------------------------------------------------------------- /providers/websockets/gate/README.md: -------------------------------------------------------------------------------- 1 | # Gate.io Provider 2 | 3 | ## Overview 4 | 5 | The Gate.io provider is used to fetch the ticker price from the [Gate.io websocket API](https://www.gate.io/docs/developers/apiv4/ws/en/#api-overview). 6 | 7 | Gate.io provides [public](https://www.gate.io/docs/developers/apiv4/ws/en/#public-trades-channel) and [authenticated](https://www.gate.io/docs/developers/apiv4/ws/en/#funding-balance-channel) channels. 8 | 9 | The Gate.io provider uses _protocol-level_ ping-pong, so no handlers need to be specifically implemented. 10 | 11 | [Application level ping messages](https://www.gate.io/docs/developers/apiv4/ws/en/#application-ping-pong) can be sent which should be responded to with pong messages. 12 | 13 | * Public channels -- No authentication is required, include tickers topic, K-Line topic, limit price topic, order book topic, and mark price topic etc. 14 | * Private channels -- including account topic, order topic, and position topic, etc. -- require log in. 15 | 16 | Users can choose to subscribe to one or more topic. This provider is implemented assuming that the user is only subscribing to public topics. 17 | 18 | The exact topic that is used to subscribe to the ticker price is the [`Tickers`](https://www.gate.io/docs/developers/apiv4/ws/en/#tickers-channel). This pushes data every 1000ms. 19 | 20 | To retrieve all supported [spot markets](https://www.gate.io/docs/developers/apiv4/en/#get-details-of-a-specific-currency), please run the following command: 21 | 22 | ```bash 23 | curl -X GET https://api.gateio.ws/api/v4/spot/currency_pairs \ 24 | -H 'Accept: application/json' 25 | ``` 26 | -------------------------------------------------------------------------------- /providers/websockets/gate/utils.go: -------------------------------------------------------------------------------- 1 | package gate 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | const ( 10 | // Name is the name of the Gate.io provider. 11 | Name = "gate_ws" 12 | // URL is the base url of for the Gate.io websocket API. 13 | URL = "wss://api.gateio.ws/ws/v4/" 14 | ) 15 | 16 | // DefaultWebSocketConfig is the default configuration for the Gate.io Websocket. 17 | var DefaultWebSocketConfig = config.WebSocketConfig{ 18 | Name: Name, 19 | Enabled: true, 20 | MaxBufferSize: 1000, 21 | ReconnectionTimeout: 10 * time.Second, 22 | PostConnectionTimeout: config.DefaultPostConnectionTimeout, 23 | Endpoints: []config.Endpoint{{URL: URL}}, 24 | ReadBufferSize: config.DefaultReadBufferSize, 25 | WriteBufferSize: config.DefaultWriteBufferSize, 26 | HandshakeTimeout: config.DefaultHandshakeTimeout, 27 | EnableCompression: config.DefaultEnableCompression, 28 | ReadTimeout: config.DefaultReadTimeout, 29 | WriteTimeout: config.DefaultWriteTimeout, 30 | PingInterval: config.DefaultPingInterval, 31 | WriteInterval: config.DefaultWriteInterval, 32 | MaxReadErrorCount: config.DefaultMaxReadErrorCount, 33 | MaxSubscriptionsPerConnection: config.DefaultMaxSubscriptionsPerConnection, 34 | MaxSubscriptionsPerBatch: config.DefaultMaxSubscriptionsPerBatch, 35 | } 36 | -------------------------------------------------------------------------------- /providers/websockets/huobi/README.md: -------------------------------------------------------------------------------- 1 | # OKX Provider 2 | 3 | ## Overview 4 | 5 | The Huobi provider is used to fetch the ticker price from the [Huobi websocket API](https://huobiapi.github.io/docs/spot/v1/en/#introduction-10). All data of websocket Market APIs are compressed with GZIP and need to be unzipped. 6 | 7 | The server will send a ping message and expect a pong sent back promptly. If a pong is not sent back after 2 pings, the connection will be disconnected. 8 | 9 | Huobi provides [public channels](https://huobiapi.github.io/docs/spot/v1/en/#introduction-10). 10 | 11 | * Public channels -- No authentication is required, include tickers channel, K-Line channel, limit price channel, order book channel, and mark price channel etc. 12 | 13 | The exact channel that is used to subscribe to the ticker price is the [`Market Tickers Topic`](https://huobiapi.github.io/docs/spot/v1/en/#market-ticker). This pushes data every 100ms. 14 | 15 | To retrieve all supported [pais](https://huobiapi.github.io/docs/spot/v1/en/#get-latest-tickers-for-all-pairs), please run the following command 16 | 17 | ```bash 18 | curl "https://api.huobi.pro/market/tickers" 19 | ``` 20 | -------------------------------------------------------------------------------- /providers/websockets/huobi/parse.go: -------------------------------------------------------------------------------- 1 | package huobi 2 | 3 | import ( 4 | "fmt" 5 | "math/big" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/skip-mev/connect/v2/oracle/types" 11 | "github.com/skip-mev/connect/v2/providers/base/websocket/handlers" 12 | ) 13 | 14 | // parseSubscriptionResponse attempts to parse a subscription message. It returns an error if the message 15 | // cannot be properly parsed. 16 | func (h *WebSocketHandler) parseSubscriptionResponse(resp SubscriptionResponse) ([]handlers.WebsocketEncodedMessage, error) { 17 | if Status(resp.Status) != StatusOk { 18 | msg, err := NewSubscriptionRequest(symbolFromSub(resp.Subbed)) 19 | return []handlers.WebsocketEncodedMessage{msg}, err 20 | } 21 | 22 | if symbolFromSub(resp.Subbed) == "" { 23 | return nil, fmt.Errorf("invalid ticker returned") 24 | } 25 | 26 | h.logger.Debug("successfully subscribed", zap.String("ticker", resp.Subbed)) 27 | return nil, nil 28 | } 29 | 30 | // parseTickerStream attempts to parse a ticker stream message. It returns a providertypes.GetResponse for the 31 | // ticker update. 32 | func (h *WebSocketHandler) parseTickerStream(stream TickerStream) (types.PriceResponse, error) { 33 | var ( 34 | resolved = make(types.ResolvedPrices) 35 | unresolved = make(types.UnResolvedPrices) 36 | ) 37 | 38 | offChainTicker := symbolFromSub(stream.Channel) 39 | if offChainTicker == "" { 40 | return types.NewPriceResponse(resolved, unresolved), 41 | fmt.Errorf("incorrectly formatted stream: %v", stream) 42 | } 43 | 44 | ticker, ok := h.cache.FromOffChainTicker(offChainTicker) 45 | if !ok { 46 | return types.NewPriceResponse(resolved, unresolved), 47 | fmt.Errorf("received stream for unknown channel %s", stream.Channel) 48 | } 49 | 50 | price := big.NewFloat(stream.Tick.LastPrice) 51 | resolved[ticker] = types.NewPriceResult(price, time.Now().UTC()) 52 | 53 | return types.NewPriceResponse(resolved, unresolved), nil 54 | } 55 | -------------------------------------------------------------------------------- /providers/websockets/huobi/utils.go: -------------------------------------------------------------------------------- 1 | package huobi 2 | 3 | import ( 4 | "github.com/skip-mev/connect/v2/oracle/config" 5 | ) 6 | 7 | const ( 8 | // Huobi provides the following URLs for its Websocket API. More info can be found in the documentation 9 | // here: https://huobiapi.github.io/docs/spot/v1/en/#websocket-market-data. 10 | 11 | // Name is the name of the Huobi provider. 12 | Name = "huobi_ws" 13 | 14 | // URL is the public Huobi Websocket URL. 15 | URL = "wss://api.huobi.pro/ws" 16 | 17 | // URLAws is the public Huobi Websocket URL hosted on AWS. 18 | URLAws = "wss://api-aws.huobi.pro/ws" 19 | ) 20 | 21 | // DefaultWebSocketConfig is the default configuration for the Huobi Websocket. 22 | var DefaultWebSocketConfig = config.WebSocketConfig{ 23 | Name: Name, 24 | Enabled: true, 25 | MaxBufferSize: 1000, 26 | ReconnectionTimeout: config.DefaultReconnectionTimeout, 27 | PostConnectionTimeout: config.DefaultPostConnectionTimeout, 28 | Endpoints: []config.Endpoint{{URL: URL}}, 29 | ReadBufferSize: config.DefaultReadBufferSize, 30 | WriteBufferSize: config.DefaultWriteBufferSize, 31 | HandshakeTimeout: config.DefaultHandshakeTimeout, 32 | EnableCompression: config.DefaultEnableCompression, 33 | ReadTimeout: config.DefaultReadTimeout, 34 | WriteTimeout: config.DefaultWriteTimeout, 35 | PingInterval: config.DefaultPingInterval, 36 | WriteInterval: config.DefaultWriteInterval, 37 | MaxReadErrorCount: config.DefaultMaxReadErrorCount, 38 | MaxSubscriptionsPerConnection: config.DefaultMaxSubscriptionsPerConnection, 39 | // Note that Huobi does not support batch subscriptions. As such each new 40 | // market will be subscribed to with a new message. 41 | MaxSubscriptionsPerBatch: config.DefaultMaxSubscriptionsPerBatch, 42 | } 43 | -------------------------------------------------------------------------------- /providers/websockets/kraken/README.md: -------------------------------------------------------------------------------- 1 | # Kraken Provider 2 | 3 | ## Overview 4 | 5 | The Kraken provider is used to fetch the ticker price from the [Kraken websocket API](https://docs.kraken.com/websockets/). 6 | 7 | 8 | ## General Considerations 9 | 10 | * TLS with SNI (Server Name Indication) is required in order to establish a Kraken WebSockets API connection. 11 | * All messages sent and received via WebSockets are encoded in JSON format 12 | * All decimal fields (including timestamps) are quoted to preserve precision. 13 | * Timestamps should not be considered unique and not be considered as aliases for transaction IDs. Also, the granularity of timestamps is not representative of transaction rates. 14 | * Please use REST API endpoint [AssetPairs](https://docs.kraken.com/rest/#tag/Market-Data/operation/getTradableAssetPairs) to fetch the list of pairs which can be subscribed via WebSockets API. For example, field 'wsname' gives the supported pairs name which can be used to subscribe. 15 | * **Recommended reconnection behaviour** is to (1) attempt reconnection instantly up to a handful of times if the websocket is dropped randomly during normal operation but (2) after maintenance or extended downtime, attempt to reconnect no more quickly than once every 5 seconds. There is no advantage to reconnecting more rapidly after maintenance during cancel_only mode. 16 | 17 | To check all available pairs, you can use the following REST API call: 18 | 19 | ```bash 20 | curl "https://api.kraken.com/0/public/Assets" 21 | ``` 22 | -------------------------------------------------------------------------------- /providers/websockets/kraken/utils.go: -------------------------------------------------------------------------------- 1 | package kraken 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/skip-mev/connect/v2/oracle/config" 7 | ) 8 | 9 | const ( 10 | // URL is the websocket URL for Kraken. You can find the documentation here: 11 | // https://docs.kraken.com/websockets/. Kraken provides an authenticated and 12 | // unauthenticated websocket. The URLs defined below are all unauthenticated. 13 | 14 | // Name is the name of the Kraken provider. 15 | Name = "kraken_ws" 16 | 17 | // URL is the production websocket URL for Kraken. 18 | URL = "wss://ws.kraken.com" 19 | 20 | // URL_BETA is the demo websocket URL for Kraken. 21 | URL_BETA = "wss://beta-ws.kraken.com" 22 | ) 23 | 24 | // DefaultWebSocketConfig is the default configuration for the Kraken Websocket. 25 | var DefaultWebSocketConfig = config.WebSocketConfig{ 26 | Name: Name, 27 | Enabled: true, 28 | MaxBufferSize: 1000, 29 | ReconnectionTimeout: 10 * time.Second, 30 | PostConnectionTimeout: config.DefaultPostConnectionTimeout, 31 | Endpoints: []config.Endpoint{{URL: URL}}, 32 | ReadBufferSize: config.DefaultReadBufferSize, 33 | WriteBufferSize: config.DefaultWriteBufferSize, 34 | HandshakeTimeout: config.DefaultHandshakeTimeout, 35 | EnableCompression: config.DefaultEnableCompression, 36 | ReadTimeout: config.DefaultReadTimeout, 37 | WriteTimeout: config.DefaultWriteTimeout, 38 | PingInterval: config.DefaultPingInterval, 39 | WriteInterval: config.DefaultWriteInterval, 40 | MaxReadErrorCount: config.DefaultMaxReadErrorCount, 41 | MaxSubscriptionsPerConnection: config.DefaultMaxSubscriptionsPerConnection, 42 | MaxSubscriptionsPerBatch: config.DefaultMaxSubscriptionsPerBatch, 43 | } 44 | -------------------------------------------------------------------------------- /providers/websockets/kucoin/README.md: -------------------------------------------------------------------------------- 1 | # KuCoin Provider 2 | 3 | ## Overview 4 | 5 | The KuCoin provider is utilized to fetch pricing data from the KuCoin websocket API. You need to apply for one of the two tokens below to create a websocket connection. It should be noted that: if you subscribe to spot/margin data, you need to obtain tokens through the spot base URL; if you subscribe to futures data, you need to obtain tokens through the futures base URL, which cannot be mixed. **Data is pushed every 100ms.** Note that the KuCoin provider requires a custom websocket connection handler to be used, as the WSS is dynamically generated at start up. 6 | 7 | This implementation subscribes to the spot markets by default, but support for future and orderbook data is also available. 8 | 9 | To determine all supported markets, you can use the [get all tickers](https://docs.kucoin.com/#get-all-tickers) endpoint. 10 | 11 | ```bash 12 | curl https://api.kucoin.com/api/v1/market/allTickers 13 | ``` 14 | -------------------------------------------------------------------------------- /providers/websockets/mexc/README.md: -------------------------------------------------------------------------------- 1 | # MEXC Provider 2 | 3 | ## Overview 4 | 5 | The MEXC provider is a websocket provider that fetches data from the MEXC exchange API. All documentation for the websocket can be found [here](https://mexcdevelop.github.io/apidocs/spot_v3_en/#websocket-market-streams). 6 | 7 | 8 | ## Considerations 9 | 10 | * A single connection to the MEXC API is made and remains valid for 24 hours before disconnecting and reconnecting. 11 | * All ticker symbols must be in uppercase in the market configuration eg: `spot@public.deals.v3.api@` -> `spot@public.deals.v3.api@BTCUSDT`. 12 | * If there is no valid websocket subscription, the server will disconnect in 30 seconds. If the subscription is successful but there is no streams, the server will disconnect in 1 minute. The client can send PING to maintain the connection. 13 | * Every websocket connection can support a maximum of 30 subscriptions. If the client needs to subscribe to more than 30 streams, it needs to connect multiple websocket connections. 14 | 15 | To determine all supported markets, you can run the following command: 16 | 17 | ```bash 18 | curl https://api.mexc.com/api/v3/defaultSymbols 19 | ``` 20 | -------------------------------------------------------------------------------- /providers/websockets/mexc/parse.go: -------------------------------------------------------------------------------- 1 | package mexc 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | providertypes "github.com/skip-mev/connect/v2/providers/types" 9 | 10 | "github.com/skip-mev/connect/v2/oracle/types" 11 | "github.com/skip-mev/connect/v2/pkg/math" 12 | ) 13 | 14 | // parseTickerResponseMessage parses a price update received from the MEXC websocket 15 | // and returns a GetResponse. 16 | func (h *WebSocketHandler) parseTickerResponseMessage( 17 | msg TickerResponseMessage, 18 | ) (types.PriceResponse, error) { 19 | var ( 20 | resolved = make(types.ResolvedPrices) 21 | unResolved = make(types.UnResolvedPrices) 22 | ) 23 | 24 | ticker, ok := h.cache.FromOffChainTicker(msg.Data.Symbol) 25 | if !ok { 26 | return types.NewPriceResponse(resolved, unResolved), 27 | fmt.Errorf("unknown ticker %s", msg.Data.Symbol) 28 | } 29 | 30 | // Ensure that the channel received is the ticker channel. 31 | if !strings.HasPrefix(msg.Channel, string(MiniTickerChannel)) { 32 | err := fmt.Errorf("invalid channel %s", msg.Channel) 33 | unResolved[ticker] = providertypes.UnresolvedResult{ 34 | ErrorWithCode: providertypes.NewErrorWithCode(err, providertypes.ErrorInvalidWebSocketTopic), 35 | } 36 | return types.NewPriceResponse(resolved, unResolved), err 37 | } 38 | 39 | // Convert the price. 40 | price, err := math.Float64StringToBigFloat(msg.Data.Price) 41 | if err != nil { 42 | unResolved[ticker] = providertypes.UnresolvedResult{ 43 | ErrorWithCode: providertypes.NewErrorWithCode(err, providertypes.ErrorFailedToParsePrice), 44 | } 45 | return types.NewPriceResponse(resolved, unResolved), err 46 | } 47 | 48 | resolved[ticker] = types.NewPriceResult(price, time.Now().UTC()) 49 | return types.NewPriceResponse(resolved, unResolved), nil 50 | } 51 | -------------------------------------------------------------------------------- /scripts/deprecated-exec.sh: -------------------------------------------------------------------------------- 1 | #!/busybox/sh 2 | 3 | echo " 4 | _ 5 | | | 6 | ___| |_ ___ _ __ 7 | / __| __/ _ \| '_ \ 8 | \__ \ || (_) | |_) | 9 | |___/\__\___/| .__/ 10 | | | 11 | |_| 12 | 13 | WARNING: This container is deprecated and will be removed in future releases. 14 | Please migrate to the corresponding Connect image." 15 | 16 | # Execute the binary passed as arguments 17 | exec "$@" -------------------------------------------------------------------------------- /scripts/protocgen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo "Generating Protocol Buffer code..." 5 | cd proto 6 | proto_dirs=$(find ./connect -path -prune -o -name '*.proto' -print0 | xargs -0 -n1 dirname | sort | uniq) 7 | for dir in $proto_dirs; do 8 | for file in $(find "${dir}" -maxdepth 1 -name '*.proto'); do 9 | if grep go_package $file &> /dev/null ; then 10 | buf generate --template buf.gen.gogo.yaml $file 11 | fi 12 | done 13 | done 14 | 15 | cd .. 16 | 17 | # move proto files to the right places 18 | cp -r github.com/skip-mev/connect/v2/* ./ 19 | rm -rf github.com 20 | 21 | # go mod tidy --compat=1.20 22 | -------------------------------------------------------------------------------- /service/clients/README.md: -------------------------------------------------------------------------------- 1 | # Clients 2 | 3 | ## Overview 4 | 5 | This directory contains all clients that are supported for the general purpose oracle. Each client is responsible for fetching data from a specific type of provider (price, random number, etc.) and returning a standardized response. The client is utilized by the validator's application (Cosmos SDK blockchain) to fetch data from the out of process oracle service before processing the data and including it in their vote extensions. 6 | 7 | ## Clients 8 | 9 | * **[Prices](./oracle/)** - This client supports fetching prices from the oracle that is aggregating price data. 10 | -------------------------------------------------------------------------------- /service/clients/oracle/README.md: -------------------------------------------------------------------------------- 1 | # Oracle Client 2 | 3 | ## Overview 4 | 5 | The oracle client is responsible for fetching data from a price oracle that is running externally from the Cosmos SDK application. The client will fetch prices, standardize them according to the preferences of the application and include them in a validator's vote extension. 6 | 7 | ```golang 8 | // OracleClient defines the interface that will be utilized by the application 9 | // to query the oracle service. This interface is meant to be implemented by 10 | // the gRPC client that connects to the oracle service. 11 | type OracleClient interface { 12 | // Prices defines a method for fetching the latest prices. 13 | Prices(ctx context.Context, in *QueryPricesRequest, opts ...grpc.CallOption) (*QueryPricesResponse, error) 14 | 15 | // Start starts the oracle client. 16 | Start() error 17 | 18 | // Stop stops the oracle client. 19 | Stop() error 20 | } 21 | ``` 22 | 23 | There are two types of clients that are supported: 24 | 25 | * [**Vanilla GRPC oracle client**](./client.go) - This client is responsible for fetching data from an oracle that is aggregating price data. It implements a GRPC client that connects to the oracle service and fetches the latest prices. 26 | * [**Metrics GRPC oracle client**](./client.go) - This client implements the same functionality as the vanilla GRPC oracle client, but also exposes metrics that can be scraped by Prometheus. 27 | 28 | To enable the metrics GRPC client, please read over the [oracle configurations](../../../oracle/config/README.md) documentation. 29 | -------------------------------------------------------------------------------- /service/clients/oracle/interface.go: -------------------------------------------------------------------------------- 1 | package oracle 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc" 7 | 8 | "github.com/skip-mev/connect/v2/service/servers/oracle/types" 9 | ) 10 | 11 | // OracleClient defines the interface that will be utilized by the application 12 | // to query the oracle service. This interface is meant to be implemented by 13 | // the gRPC client that connects to the oracle service. 14 | // 15 | //go:generate mockery --name OracleClient --filename mock_oracle_client.go 16 | type OracleClient interface { //nolint 17 | types.OracleClient 18 | 19 | // Start starts the oracle client. This should connect to the remote oracle 20 | // service and return an error if the connection fails. 21 | Start(context.Context) error 22 | 23 | // Stop stops the oracle client. 24 | Stop() error 25 | } 26 | 27 | // NoOpClient is a no-op implementation of the OracleClient interface. This 28 | // implementation is used when the oracle service is disabled. 29 | type NoOpClient struct{} 30 | 31 | // Start is a no-op. 32 | func (NoOpClient) Start(context.Context) error { 33 | return nil 34 | } 35 | 36 | // Stop is a no-op. 37 | func (NoOpClient) Stop() error { 38 | return nil 39 | } 40 | 41 | // Prices is a no-op. 42 | func (NoOpClient) Prices( 43 | _ context.Context, 44 | _ *types.QueryPricesRequest, 45 | _ ...grpc.CallOption, 46 | ) (*types.QueryPricesResponse, error) { 47 | return nil, nil 48 | } 49 | 50 | func (c NoOpClient) MarketMap( 51 | _ context.Context, 52 | _ *types.QueryMarketMapRequest, 53 | _ ...grpc.CallOption, 54 | ) (*types.QueryMarketMapResponse, error) { 55 | return nil, nil 56 | } 57 | 58 | func (c NoOpClient) Version( 59 | _ context.Context, 60 | _ *types.QueryVersionRequest, 61 | _ ...grpc.CallOption, 62 | ) (*types.QueryVersionResponse, error) { 63 | return nil, nil 64 | } 65 | -------------------------------------------------------------------------------- /service/clients/oracle/options.go: -------------------------------------------------------------------------------- 1 | package oracle 2 | 3 | // Option enables consumers to configure the behavior of an OracleClient on initialization. 4 | type Option func(OracleClient) 5 | 6 | // WithBlockingDial configures the OracleClient to block on dialing the remote oracle server. 7 | // 8 | // NOTICE: This option is not recommended to be used in practice. See the [GRPC docs](https://github.com/grpc/grpc-go/blob/master/Documentation/anti-patterns.md) 9 | func WithBlockingDial() Option { 10 | return func(c OracleClient) { 11 | client, ok := c.(*GRPCClient) 12 | if !ok { 13 | return 14 | } 15 | 16 | client.blockingDial = true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /service/servers/README.md: -------------------------------------------------------------------------------- 1 | # Servers 2 | 3 | ## Overview 4 | 5 | This directory contains all of the servers that are supported for the general purpose oracle as well as an metrics instrumentation server that can be used to expose metrics to Prometheus. Each non-metrics server is responsible for running a GRPC server that internally exposes data from a general purpose oracle. 6 | 7 | 8 | ## Servers 9 | 10 | * **[Price Oracle Server](./oracle/)** - This server is responsible for running a GRPC server that exposes price data from a price oracle. This server is meant to be run alongside a Cosmos SDK application that is utilizing the general purpose oracle module. However, it can also be run as a standalone server that exposes price data to any application that is able to connect to a GRPC server. 11 | * **[Metrics Server](./metrics/)** - This server is responsible for running a GRPC server that exposes metrics that can be scraped by Prometheus. Specifically, this server can expose metrics data from the price oracle server as well as the price oracle client. 12 | 13 | ## Usage 14 | 15 | To enable any of these servers, please read over the [oracle configurations](../../oracle/config/README.md) documentation. 16 | -------------------------------------------------------------------------------- /service/servers/oracle/errors.go: -------------------------------------------------------------------------------- 1 | package oracle 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNilRequest = errors.New("request cannot be nil") 7 | ErrOracleNotRunning = errors.New("oracle is not running") 8 | ErrContextCancelled = errors.New("context cancelled") 9 | ) 10 | -------------------------------------------------------------------------------- /service/servers/oracle/helpers.go: -------------------------------------------------------------------------------- 1 | package oracle 2 | 3 | import ( 4 | "github.com/skip-mev/connect/v2/oracle/types" 5 | ) 6 | 7 | func ToReqPrices(prices types.Prices) map[string]string { 8 | reqPrices := make(map[string]string, len(prices)) 9 | 10 | for cp, price := range prices { 11 | intPrice, _ := price.Int(nil) 12 | reqPrices[cp] = intPrice.String() 13 | } 14 | 15 | return reqPrices 16 | } 17 | -------------------------------------------------------------------------------- /service/servers/oracle/interface.go: -------------------------------------------------------------------------------- 1 | package oracle 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/skip-mev/connect/v2/service/servers/oracle/types" 7 | ) 8 | 9 | // OracleService defines the service all clients must implement. 10 | // 11 | //go:generate mockery --name OracleService --filename mock_oracle_service.go 12 | type OracleService interface { //nolint 13 | types.OracleServer 14 | 15 | Start(context.Context) error 16 | Stop(context.Context) error 17 | } 18 | -------------------------------------------------------------------------------- /service/servers/prometheus/server_test.go: -------------------------------------------------------------------------------- 1 | package prometheus_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | "go.uber.org/zap" 10 | 11 | "github.com/skip-mev/connect/v2/service/servers/prometheus" 12 | ) 13 | 14 | // Test that Starting the server fails if the address is incorrect. 15 | func TestStart(t *testing.T) { 16 | t.Run("Start fails with incorrect address", func(t *testing.T) { 17 | address := ":8081" 18 | 19 | ps, err := prometheus.NewPrometheusServer(address, nil) 20 | require.Nil(t, ps) 21 | require.Error(t, err, "invalid prometheus server address: :8080") 22 | }) 23 | 24 | t.Run("Start succeeds with correct address", func(t *testing.T) { 25 | address := "0.0.0.0:8081" 26 | 27 | ps, err := prometheus.NewPrometheusServer(address, zap.NewNop()) 28 | require.NotNil(t, ps) 29 | require.NoError(t, err) 30 | 31 | // start the server 32 | go ps.Start() 33 | 34 | time.Sleep(1 * time.Second) 35 | 36 | // ping the server 37 | require.True(t, pingServer("http://"+address)) 38 | 39 | // close the server 40 | ps.Close() 41 | 42 | // expect the server to be closed within 3 seconds 43 | select { 44 | case <-ps.Done(): 45 | case <-time.After(3 * time.Second): 46 | } 47 | }) 48 | } 49 | 50 | func pingServer(address string) bool { 51 | timeout := 5 * time.Second 52 | client := http.Client{ 53 | Timeout: timeout, 54 | } 55 | 56 | resp, err := client.Get(address) 57 | if err != nil { 58 | return false 59 | } 60 | defer resp.Body.Close() 61 | 62 | return resp.StatusCode == http.StatusOK 63 | } 64 | -------------------------------------------------------------------------------- /service/validation/validation_test.go: -------------------------------------------------------------------------------- 1 | package validation_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/service/validation" 9 | ) 10 | 11 | func TestConfig_Validate(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | config validation.Config 15 | wantErr bool 16 | }{ 17 | { 18 | name: "empty invalid", 19 | wantErr: true, 20 | }, 21 | { 22 | name: "valid default", 23 | config: validation.DefaultConfig(), 24 | wantErr: false, 25 | }, 26 | { 27 | name: "invalid percent", 28 | config: validation.Config{ 29 | BurnInPeriod: validation.DefaultValidationPeriod, 30 | ValidationPeriod: validation.DefaultValidationPeriod, 31 | NumChecks: validation.DefaultNumChecks, 32 | RequiredPriceLivenessPercent: 0, 33 | }, 34 | wantErr: true, 35 | }, 36 | { 37 | name: "invalid percent", 38 | config: validation.Config{ 39 | BurnInPeriod: validation.DefaultValidationPeriod, 40 | ValidationPeriod: validation.DefaultValidationPeriod, 41 | NumChecks: 0, 42 | RequiredPriceLivenessPercent: validation.DefaultRequiredPriceLivenessPercent, 43 | }, 44 | wantErr: true, 45 | }, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | err := tt.config.Validate() 50 | if tt.wantErr { 51 | require.Error(t, err) 52 | return 53 | } 54 | 55 | require.NoError(t, err) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/petri/connect_suite.go: -------------------------------------------------------------------------------- 1 | package petri 2 | 3 | import ( 4 | "context" 5 | 6 | petritypes "github.com/skip-mev/petri/types/v2" 7 | "github.com/stretchr/testify/suite" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // ConnectIntegrationSuite is the testify suite for building and running connect testapp networks using petri 12 | type ConnectIntegrationSuite struct { 13 | suite.Suite 14 | 15 | logger *zap.Logger 16 | 17 | spec *petritypes.ChainConfig 18 | 19 | chain petritypes.ChainI 20 | } 21 | 22 | func NewConnectIntegrationSuite(spec *petritypes.ChainConfig) *ConnectIntegrationSuite { 23 | return &ConnectIntegrationSuite{ 24 | spec: spec, 25 | } 26 | } 27 | 28 | func (s *ConnectIntegrationSuite) SetupSuite() { 29 | // create the logger 30 | var err error 31 | s.logger, err = zap.NewDevelopment() 32 | s.Require().NoError(err) 33 | 34 | // create the chain 35 | s.chain, err = GetChain(context.Background(), s.logger) 36 | s.Require().NoError(err) 37 | 38 | // initialize the chain 39 | err = s.chain.Init(context.Background()) 40 | s.Require().NoError(err) 41 | } 42 | 43 | func (s *ConnectIntegrationSuite) TearDownSuite() { 44 | err := s.chain.Teardown(context.Background()) 45 | s.Require().NoError(err) 46 | } 47 | 48 | // TestConnectIntegration waits for the chain to reach height 5 49 | func (s *ConnectIntegrationSuite) TestConnectIntegration() { 50 | err := s.chain.WaitForHeight(context.Background(), 5) 51 | s.Require().NoError(err) 52 | } 53 | -------------------------------------------------------------------------------- /tests/petri/connect_test.go: -------------------------------------------------------------------------------- 1 | package petri_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/suite" 7 | 8 | "github.com/skip-mev/connect/v2/tests/petri" 9 | ) 10 | 11 | // TestConnectIntegration runs all petri connect testapp tests 12 | func TestConnectIntegration(t *testing.T) { 13 | chainCfg := petri.GetChainConfig() 14 | suite.Run(t, petri.NewConnectIntegrationSuite(&chainCfg)) 15 | } 16 | -------------------------------------------------------------------------------- /tests/simapp/connectd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "cosmossdk.io/log" 7 | svrcmd "github.com/cosmos/cosmos-sdk/server/cmd" 8 | 9 | "github.com/skip-mev/connect/v2/tests/simapp" 10 | cmd "github.com/skip-mev/connect/v2/tests/simapp/connectd/testappd" 11 | ) 12 | 13 | func main() { 14 | rootCmd := cmd.NewRootCmd() 15 | if err := svrcmd.Execute(rootCmd, "", simapp.DefaultNodeHome); err != nil { 16 | log.NewLogger(rootCmd.OutOrStderr()).Error("failure when running app", "err", err) 17 | os.Exit(1) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/simapp/helpers.go: -------------------------------------------------------------------------------- 1 | package simapp 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "cosmossdk.io/log" 8 | dbm "github.com/cosmos/cosmos-db" 9 | 10 | pruningtypes "cosmossdk.io/store/pruning/types" 11 | 12 | bam "github.com/cosmos/cosmos-sdk/baseapp" 13 | "github.com/cosmos/cosmos-sdk/client/flags" 14 | servertypes "github.com/cosmos/cosmos-sdk/server/types" 15 | "github.com/cosmos/cosmos-sdk/testutil/network" 16 | simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims" 17 | "github.com/cosmos/cosmos-sdk/types/module/testutil" 18 | ) 19 | 20 | // NewTestNetworkFixture returns a new simapp AppConstructor for network simulation tests. 21 | func NewTestNetworkFixture() network.TestFixture { 22 | dir, err := os.MkdirTemp("", "simapp") 23 | if err != nil { 24 | panic(fmt.Sprintf("failed creating temporary directory: %v", err)) 25 | } 26 | defer os.RemoveAll(dir) 27 | 28 | app := NewSimApp(log.NewNopLogger(), dbm.NewMemDB(), nil, true, simtestutil.NewAppOptionsWithFlagHome(dir)) 29 | 30 | appCtr := func(val network.ValidatorI) servertypes.Application { 31 | return NewSimApp( 32 | val.GetCtx().Logger, dbm.NewMemDB(), nil, true, 33 | simtestutil.NewAppOptionsWithFlagHome(val.GetCtx().Config.RootDir), 34 | bam.SetPruning(pruningtypes.NewPruningOptionsFromString(val.GetAppConfig().Pruning)), 35 | bam.SetMinGasPrices(val.GetAppConfig().MinGasPrices), 36 | bam.SetChainID(val.GetCtx().Viper.GetString(flags.FlagChainID)), 37 | ) 38 | } 39 | 40 | return network.TestFixture{ 41 | AppConstructor: appCtr, 42 | GenesisState: app.DefaultGenesis(), 43 | EncodingConfig: testutil.TestEncodingConfig{ 44 | InterfaceRegistry: app.InterfaceRegistry(), 45 | Codec: app.AppCodec(), 46 | TxConfig: app.TxConfig(), 47 | Amino: app.LegacyAmino(), 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/simapp/params/encoding.go: -------------------------------------------------------------------------------- 1 | package params 2 | 3 | import ( 4 | "github.com/cosmos/cosmos-sdk/client" 5 | "github.com/cosmos/cosmos-sdk/codec" 6 | "github.com/cosmos/cosmos-sdk/codec/types" 7 | ) 8 | 9 | // EncodingConfig specifies the concrete encoding types to use for a given app. 10 | // This is provided for compatibility between protobuf and amino implementations. 11 | type EncodingConfig struct { 12 | InterfaceRegistry types.InterfaceRegistry 13 | Codec codec.Codec 14 | TxConfig client.TxConfig 15 | Amino *codec.LegacyAmino 16 | } 17 | -------------------------------------------------------------------------------- /testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import "math/rand" 4 | 5 | // RandomString generates a random string of length N. 6 | func RandomString(length int) string { 7 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 8 | result := make([]byte, length) 9 | for i := range result { 10 | result[i] = charset[rand.Intn(len(charset))] 11 | } 12 | return string(result) 13 | } 14 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | // This is the canonical way to enforce dependency inclusion in go.mod for tools that are not directly involved in the build process. 5 | // See 6 | // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 7 | 8 | package tools 9 | 10 | //nolint 11 | 12 | import ( 13 | _ "github.com/client9/misspell" 14 | _ "github.com/golangci/golangci-lint/cmd/golangci-lint" 15 | _ "github.com/vektra/mockery/v2" 16 | _ "golang.org/x/vuln/cmd/govulncheck" 17 | _ "mvdan.cc/gofumpt" 18 | ) 19 | -------------------------------------------------------------------------------- /x/marketmap/keeper/genesis.go: -------------------------------------------------------------------------------- 1 | package keeper 2 | 3 | import ( 4 | sdk "github.com/cosmos/cosmos-sdk/types" 5 | 6 | "github.com/skip-mev/connect/v2/x/marketmap/types" 7 | ) 8 | 9 | // InitGenesis initializes the genesis state. Panics if there is an error. 10 | // Any modules that integrate with x/marketmap must set their InitGenesis to occur before the x/marketmap 11 | // module's InitGenesis. This is so that logic any consuming modules may want to implement in AfterMarketGenesis 12 | // will be run properly. 13 | func (k *Keeper) InitGenesis(ctx sdk.Context, gs types.GenesisState) { 14 | // validate the genesis 15 | if err := gs.ValidateBasic(); err != nil { 16 | panic(err) 17 | } 18 | 19 | for _, market := range gs.MarketMap.Markets { 20 | if err := k.CreateMarket(ctx, market); err != nil { 21 | panic(err) 22 | } 23 | } 24 | 25 | if err := k.SetLastUpdated(ctx, gs.LastUpdated); err != nil { 26 | panic(err) 27 | } 28 | 29 | if err := k.SetParams(ctx, gs.Params); err != nil { 30 | panic(err) 31 | } 32 | 33 | if k.hooks != nil { 34 | if err := k.hooks.AfterMarketGenesis(ctx, gs.MarketMap.Markets); err != nil { 35 | panic(err) 36 | } 37 | } 38 | } 39 | 40 | // ExportGenesis retrieves the genesis from state. 41 | func (k *Keeper) ExportGenesis(ctx sdk.Context) *types.GenesisState { 42 | markets, err := k.GetAllMarkets(ctx) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | lastUpdated, err := k.GetLastUpdated(ctx) 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | params, err := k.GetParams(ctx) 53 | if err != nil { 54 | panic(err) 55 | } 56 | 57 | return &types.GenesisState{ 58 | MarketMap: types.MarketMap{ 59 | Markets: markets, 60 | }, 61 | LastUpdated: lastUpdated, 62 | Params: params, 63 | } 64 | } 65 | 66 | // InitializeForGenesis is a no-op. 67 | func (k *Keeper) InitializeForGenesis(_ sdk.Context) {} 68 | -------------------------------------------------------------------------------- /x/marketmap/keeper/hooks.go: -------------------------------------------------------------------------------- 1 | package keeper 2 | 3 | import ( 4 | "github.com/skip-mev/connect/v2/x/marketmap/types" 5 | ) 6 | 7 | // Hooks gets the hooks for x/marketmap keeper. 8 | func (k *Keeper) Hooks() types.MarketMapHooks { 9 | if k.hooks == nil { 10 | // return a no-op implementation if no hooks are set 11 | return &types.NoopMarketMapHooks{} 12 | } 13 | 14 | return k.hooks 15 | } 16 | 17 | // SetHooks sets the x/marketmap hooks. In contrast to other receivers, this method must take a pointer due to nature 18 | // of the hooks interface and SDK start up sequence. 19 | func (k *Keeper) SetHooks(mmh types.MarketMapHooks) { 20 | k.hooks = mmh 21 | } 22 | -------------------------------------------------------------------------------- /x/marketmap/keeper/options.go: -------------------------------------------------------------------------------- 1 | package keeper 2 | 3 | import "github.com/skip-mev/connect/v2/x/marketmap/types" 4 | 5 | // Option is a type that modifies a keeper during instantiation. These can be passed variadically into NewKeeper 6 | // to specify keeper behavior. 7 | type Option func(*Keeper) 8 | 9 | // WithHooks sets the keeper hooks to the given hooks. 10 | func WithHooks(hooks types.MarketMapHooks) Option { 11 | return func(k *Keeper) { 12 | k.hooks = hooks 13 | } 14 | } 15 | 16 | // WithDeleteValidationHooks sets the keeper deleteMarketValidationHooks to the given hooks. 17 | func WithDeleteValidationHooks(hooks []types.MarketValidationHook) Option { 18 | return func(k *Keeper) { 19 | k.deleteMarketValidationHooks = hooks 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /x/marketmap/keeper/validation_hooks.go: -------------------------------------------------------------------------------- 1 | package keeper 2 | -------------------------------------------------------------------------------- /x/marketmap/types/client.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | //go:generate mockery --name QueryClient 4 | type ClientWrapper interface { 5 | QueryClient 6 | } 7 | -------------------------------------------------------------------------------- /x/marketmap/types/codec.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/cosmos/cosmos-sdk/codec" 5 | "github.com/cosmos/cosmos-sdk/codec/legacy" 6 | codectypes "github.com/cosmos/cosmos-sdk/codec/types" 7 | sdk "github.com/cosmos/cosmos-sdk/types" 8 | "github.com/cosmos/cosmos-sdk/types/msgservice" 9 | ) 10 | 11 | // RegisterLegacyAminoCodec registers the necessary x/marketmap interfaces (messages) on the 12 | // cdc. These types are used for amino serialization. 13 | func RegisterLegacyAminoCodec(cdc *codec.LegacyAmino) { 14 | // register the msg-types 15 | legacy.RegisterAminoMsg(cdc, &MsgCreateMarkets{}, "connect/x/marketmap/MsgCreateMarkets") 16 | legacy.RegisterAminoMsg(cdc, &MsgUpdateMarkets{}, "connect/x/marketmap/MsgUpdateMarkets") 17 | legacy.RegisterAminoMsg(cdc, &MsgParams{}, "connect/x/marketmap/MsgParams") 18 | legacy.RegisterAminoMsg(cdc, &MsgUpsertMarkets{}, "connect/x/marketmap/MsgUpsertMarkets") 19 | legacy.RegisterAminoMsg(cdc, &MsgRemoveMarkets{}, "connect/x/marketmap/MsgRemoveMarkets") 20 | } 21 | 22 | // RegisterInterfaces registers the x/marketmap messages + message service w/ the InterfaceRegistry (registry). 23 | func RegisterInterfaces(registry codectypes.InterfaceRegistry) { 24 | // register the implementations of Msg-type 25 | registry.RegisterImplementations((*sdk.Msg)(nil), 26 | &MsgCreateMarkets{}, 27 | &MsgUpdateMarkets{}, 28 | &MsgParams{}, 29 | &MsgUpsertMarkets{}, 30 | &MsgRemoveMarkets{}, 31 | &MsgRemoveMarketAuthorities{}, 32 | ) 33 | 34 | msgservice.RegisterMsgServiceDesc(registry, &_Msg_serviceDesc) 35 | } 36 | -------------------------------------------------------------------------------- /x/marketmap/types/errors.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "fmt" 4 | 5 | // MarketAlreadyExistsError is an error indicating the given Market exists in state. 6 | type MarketAlreadyExistsError struct { 7 | ticker TickerString 8 | } 9 | 10 | func NewMarketAlreadyExistsError(ticker TickerString) MarketAlreadyExistsError { 11 | return MarketAlreadyExistsError{ticker: ticker} 12 | } 13 | 14 | // Error returns the error string for MarketAlreadyExistsError. 15 | func (e MarketAlreadyExistsError) Error() string { 16 | return fmt.Sprintf("market already exists for ticker %s", e.ticker) 17 | } 18 | 19 | // MarketDoesNotExistsError is an error indicating the given Market does not exist in state. 20 | type MarketDoesNotExistsError struct { 21 | ticker TickerString 22 | } 23 | 24 | func NewMarketDoesNotExistsError(ticker TickerString) MarketDoesNotExistsError { 25 | return MarketDoesNotExistsError{ticker: ticker} 26 | } 27 | 28 | // Error returns the error string for MarketDoesNotExistsError. 29 | func (e MarketDoesNotExistsError) Error() string { 30 | return fmt.Sprintf("market does not exist for ticker %s", e.ticker) 31 | } 32 | 33 | // MarketIsEnabledError is an error indicating the given Market does not exist in state. 34 | type MarketIsEnabledError struct { 35 | ticker TickerString 36 | } 37 | 38 | func NewMarketIsEnabledError(ticker TickerString) MarketIsEnabledError { 39 | return MarketIsEnabledError{ticker: ticker} 40 | } 41 | 42 | // Error returns the error string for MarketIsEnabledError. 43 | func (e MarketIsEnabledError) Error() string { 44 | return fmt.Sprintf("market is currently enabled %s", e.ticker) 45 | } 46 | -------------------------------------------------------------------------------- /x/marketmap/types/events.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // market map module event types 4 | 5 | const ( 6 | EventTypeCreateMarket = "create_market" 7 | EventTypeUpdateMarket = "update_market" 8 | 9 | AttributeKeyCurrencyPair = "currency_pair" 10 | AttributeKeyDecimals = "decimals" 11 | AttributeKeyMinProviderCount = "min_provider_count" 12 | AttributeKeyMetadata = "metadata" 13 | ) 14 | -------------------------------------------------------------------------------- /x/marketmap/types/genesis.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // NewGenesisState returns an instance of GenesisState. 4 | func NewGenesisState( 5 | marketMap MarketMap, 6 | lastUpdated uint64, 7 | params Params, 8 | ) GenesisState { 9 | return GenesisState{ 10 | MarketMap: marketMap, 11 | LastUpdated: lastUpdated, 12 | Params: params, 13 | } 14 | } 15 | 16 | // ValidateBasic performs basic validation on the GenesisState. 17 | func (gs *GenesisState) ValidateBasic() error { 18 | if err := gs.MarketMap.ValidateBasic(); err != nil { 19 | return err 20 | } 21 | 22 | return gs.Params.ValidateBasic() 23 | } 24 | 25 | // DefaultGenesisState returns the default genesis of the marketmap module. 26 | func DefaultGenesisState() *GenesisState { 27 | return &GenesisState{ 28 | MarketMap: MarketMap{ 29 | Markets: make(map[string]Market), 30 | }, 31 | LastUpdated: 0, 32 | Params: DefaultParams(), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /x/marketmap/types/genesis_test.go: -------------------------------------------------------------------------------- 1 | package types_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/x/marketmap/types" 9 | ) 10 | 11 | func TestGenesisState(t *testing.T) { 12 | t.Run("invalid empty genesis state - fail", func(t *testing.T) { 13 | gs := types.GenesisState{} 14 | require.Error(t, gs.ValidateBasic()) 15 | }) 16 | 17 | t.Run("invalid params - fail", func(t *testing.T) { 18 | gs := types.DefaultGenesisState() 19 | 20 | gs.Params.MarketAuthorities = []string{"invalid"} 21 | require.Error(t, gs.ValidateBasic()) 22 | }) 23 | 24 | t.Run("good populated genesis state", func(t *testing.T) { 25 | gs := types.GenesisState{ 26 | MarketMap: types.MarketMap{ 27 | Markets: markets, 28 | }, 29 | Params: types.DefaultParams(), 30 | } 31 | require.NoError(t, gs.ValidateBasic()) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /x/marketmap/types/keys.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "cosmossdk.io/collections" 5 | "cosmossdk.io/collections/codec" 6 | ) 7 | 8 | const ( 9 | // ModuleName defines the canonical name identifying the module. 10 | ModuleName = "marketmap" 11 | // StoreKey holds the unique key used to access the module keeper's KVStore. 12 | StoreKey = ModuleName 13 | ) 14 | 15 | var ( 16 | // LastUpdatedPrefix is the key prefix for the lastUpdated height. 17 | LastUpdatedPrefix = collections.NewPrefix(1) 18 | 19 | // MarketsPrefix is the key prefix for Markets. 20 | MarketsPrefix = collections.NewPrefix(2) 21 | 22 | // ParamsPrefix is the key prefix of the module Params. 23 | ParamsPrefix = collections.NewPrefix(3) 24 | 25 | // TickersCodec is the collections.KeyCodec value used for the markets map. 26 | TickersCodec = codec.NewStringKeyCodec[TickerString]() 27 | 28 | // LastUpdatedCodec is the collections.KeyCodec value used for the lastUpdated value. 29 | LastUpdatedCodec = codec.KeyToValueCodec[uint64](codec.NewUint64Key[uint64]()) 30 | ) 31 | 32 | // TickerString is the key used to identify unique pairs of Base/Quote with corresponding PathsConfig objects--or in other words AggregationConfigs. 33 | // The TickerString is identical to Connect's CurrencyPair.String() output in that it is `Base` and `Quote` joined by `/` i.e. `$BASE/$QUOTE`. 34 | type TickerString string 35 | -------------------------------------------------------------------------------- /x/marketmap/types/params.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | 6 | sdk "github.com/cosmos/cosmos-sdk/types" 7 | authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" 8 | govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" 9 | ) 10 | 11 | // DefaultParams returns default marketmap parameters. 12 | func DefaultParams() Params { 13 | return Params{ 14 | MarketAuthorities: []string{authtypes.NewModuleAddress(govtypes.ModuleName).String()}, 15 | Admin: authtypes.NewModuleAddress(govtypes.ModuleName).String(), 16 | } 17 | } 18 | 19 | // NewParams returns a new Params instance. 20 | func NewParams(authorities []string, admin string) (Params, error) { 21 | if authorities == nil { 22 | return Params{}, fmt.Errorf("cannot create Params with nil authority") 23 | } 24 | 25 | return Params{ 26 | MarketAuthorities: authorities, 27 | Admin: admin, 28 | }, nil 29 | } 30 | 31 | // ValidateBasic performs stateless validation of the Params. 32 | func (p *Params) ValidateBasic() error { 33 | if p.MarketAuthorities == nil { 34 | return fmt.Errorf("cannot create Params with empty market authorities") 35 | } 36 | 37 | seenAuthorities := make(map[string]struct{}, len(p.MarketAuthorities)) 38 | for _, authority := range p.MarketAuthorities { 39 | if _, seen := seenAuthorities[authority]; seen { 40 | return fmt.Errorf("duplicate authority %s found", authority) 41 | } 42 | 43 | if _, err := sdk.AccAddressFromBech32(authority); err != nil { 44 | return fmt.Errorf("invalid market authority string: %w", err) 45 | } 46 | 47 | seenAuthorities[authority] = struct{}{} 48 | } 49 | 50 | if _, err := sdk.AccAddressFromBech32(p.Admin); err != nil { 51 | return fmt.Errorf("invalid marketmap admin string: %w", err) 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /x/marketmap/types/provider.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/skip-mev/connect/v2/pkg/json" 7 | ) 8 | 9 | // ValidateBasic performs basic validation on a ProviderConfig. 10 | func (pc *ProviderConfig) ValidateBasic() error { 11 | if len(pc.Name) == 0 { 12 | return fmt.Errorf("provider name must not be empty") 13 | } 14 | 15 | if len(pc.OffChainTicker) == 0 { 16 | return fmt.Errorf("provider offchain ticker must not be empty") 17 | } 18 | 19 | // NormalizeByPair is allowed to be empty 20 | if pc.NormalizeByPair != nil { 21 | if err := pc.NormalizeByPair.ValidateBasic(); err != nil { 22 | return err 23 | } 24 | } 25 | 26 | if len(pc.Metadata_JSON) > MaxMetadataJSONFieldLength { 27 | return fmt.Errorf("metadata json field is longer than maximum length of %d", MaxMetadataJSONFieldLength) 28 | } 29 | 30 | if err := json.IsValid([]byte(pc.Metadata_JSON)); err != nil { 31 | return fmt.Errorf("invalid provider config metadata json: %w", err) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // Equal returns true iff the ProviderConfig is equal to the given ProviderConfig. 38 | func (pc *ProviderConfig) Equal(other ProviderConfig) bool { 39 | if pc.Name != other.Name { 40 | return false 41 | } 42 | 43 | if pc.OffChainTicker != other.OffChainTicker { 44 | return false 45 | } 46 | 47 | if pc.Invert != other.Invert { 48 | return false 49 | } 50 | 51 | if pc.NormalizeByPair == nil { 52 | if other.NormalizeByPair != nil { 53 | return false 54 | } 55 | } else { 56 | if other.NormalizeByPair == nil { 57 | return false 58 | } 59 | 60 | if !pc.NormalizeByPair.Equal(*other.NormalizeByPair) { 61 | return false 62 | } 63 | } 64 | 65 | return pc.Metadata_JSON == other.Metadata_JSON 66 | } 67 | -------------------------------------------------------------------------------- /x/marketmap/types/tickermetadata/common.go: -------------------------------------------------------------------------------- 1 | package tickermetadata 2 | 3 | import "encoding/json" 4 | 5 | type AggregatorID struct { 6 | // Venue is the name of the aggregator for which the ID is valid. 7 | // E.g. `coingecko`, `cmc` 8 | Venue string `json:"venue"` 9 | // ID is the string ID of the Ticker's Base denom in the aggregator. 10 | ID string `json:"ID"` 11 | } 12 | 13 | // NewAggregatorID returns a new AggregatorID instance. 14 | func NewAggregatorID(venue, id string) AggregatorID { 15 | return AggregatorID{ 16 | Venue: venue, 17 | ID: id, 18 | } 19 | } 20 | 21 | // MarshalAggregatorID returns the JSON byte encoding of the AggregatorID. 22 | func MarshalAggregatorID(m AggregatorID) ([]byte, error) { 23 | return json.Marshal(m) 24 | } 25 | 26 | // AggregatorIDFromJSONString returns an AggregatorID instance from a JSON string. 27 | func AggregatorIDFromJSONString(jsonString string) (AggregatorID, error) { 28 | var elem AggregatorID 29 | err := json.Unmarshal([]byte(jsonString), &elem) 30 | return elem, err 31 | } 32 | 33 | // AggregatorIDFromJSONBytes returns an AggregatorID instance from JSON bytes. 34 | func AggregatorIDFromJSONBytes(jsonBytes []byte) (AggregatorID, error) { 35 | var elem AggregatorID 36 | err := json.Unmarshal(jsonBytes, &elem) 37 | return elem, err 38 | } 39 | -------------------------------------------------------------------------------- /x/marketmap/types/tickermetadata/common_test.go: -------------------------------------------------------------------------------- 1 | package tickermetadata_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/x/marketmap/types/tickermetadata" 9 | ) 10 | 11 | func Test_UnmarshalAggregatorID(t *testing.T) { 12 | t.Run("can marshal and unmarshal the same struct and values", func(t *testing.T) { 13 | elem := tickermetadata.NewAggregatorID("coingecko", "id") 14 | 15 | bz, err := tickermetadata.MarshalAggregatorID(elem) 16 | require.NoError(t, err) 17 | 18 | elem2, err := tickermetadata.AggregatorIDFromJSONBytes(bz) 19 | require.NoError(t, err) 20 | require.Equal(t, elem, elem2) 21 | }) 22 | 23 | t.Run("can unmarshal a JSON string into a struct", func(t *testing.T) { 24 | elemJSON := `{"venue":"coingecko","ID":"id"}` 25 | elem, err := tickermetadata.AggregatorIDFromJSONString(elemJSON) 26 | require.NoError(t, err) 27 | 28 | require.Equal(t, tickermetadata.NewAggregatorID("coingecko", "id"), elem) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /x/marketmap/types/tickermetadata/core.go: -------------------------------------------------------------------------------- 1 | package tickermetadata 2 | 3 | import "encoding/json" 4 | 5 | // CoreMetadata is the Ticker.Metadata_JSON published to every Ticker in the x/marketmap module on core markets. 6 | type CoreMetadata struct { 7 | // AggregateIDs contains a list of AggregatorIDs associated with the ticker. 8 | // This field may not be populated if no aggregator currently indexes this Ticker. 9 | AggregateIDs []AggregatorID `json:"aggregate_ids"` 10 | } 11 | 12 | // NewCoreMetadata returns a new CoreMetadata instance. 13 | func NewCoreMetadata(aggregateIDs []AggregatorID) CoreMetadata { 14 | return CoreMetadata{ 15 | AggregateIDs: aggregateIDs, 16 | } 17 | } 18 | 19 | // MarshalCoreMetadata returns the JSON byte encoding of the CoreMetadata. 20 | func MarshalCoreMetadata(m CoreMetadata) ([]byte, error) { 21 | return json.Marshal(m) 22 | } 23 | 24 | // CoreMetadataFromJSONString returns a CoreMetadata instance from a JSON string. 25 | func CoreMetadataFromJSONString(jsonString string) (CoreMetadata, error) { 26 | var elem CoreMetadata 27 | err := json.Unmarshal([]byte(jsonString), &elem) 28 | return elem, err 29 | } 30 | 31 | // CoreMetadataFromJSONBytes returns a CoreMetadata instance from JSON bytes. 32 | func CoreMetadataFromJSONBytes(jsonBytes []byte) (CoreMetadata, error) { 33 | var elem CoreMetadata 34 | err := json.Unmarshal(jsonBytes, &elem) 35 | return elem, err 36 | } 37 | -------------------------------------------------------------------------------- /x/marketmap/types/tickermetadata/core_test.go: -------------------------------------------------------------------------------- 1 | package tickermetadata_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/x/marketmap/types/tickermetadata" 9 | ) 10 | 11 | func Test_UnmarshalCoreMetadata(t *testing.T) { 12 | t.Run("can marshal and unmarshal the same struct and values", func(t *testing.T) { 13 | elem := tickermetadata.NewCoreMetadata( 14 | []tickermetadata.AggregatorID{ 15 | tickermetadata.NewAggregatorID("coingecko", "id"), 16 | tickermetadata.NewAggregatorID("cmc", "id"), 17 | }, 18 | ) 19 | 20 | bz, err := tickermetadata.MarshalCoreMetadata(elem) 21 | require.NoError(t, err) 22 | 23 | elem2, err := tickermetadata.CoreMetadataFromJSONBytes(bz) 24 | require.NoError(t, err) 25 | require.Equal(t, elem, elem2) 26 | }) 27 | 28 | t.Run("can marshal and unmarshal the same struct and values with empty AggregatorIDs", func(t *testing.T) { 29 | elem := tickermetadata.NewCoreMetadata(nil) 30 | 31 | bz, err := tickermetadata.MarshalCoreMetadata(elem) 32 | require.NoError(t, err) 33 | 34 | elem2, err := tickermetadata.CoreMetadataFromJSONBytes(bz) 35 | require.NoError(t, err) 36 | require.Equal(t, elem, elem2) 37 | }) 38 | 39 | t.Run("can unmarshal a JSON string into a struct", func(t *testing.T) { 40 | elemJSON := `{"aggregate_ids":[{"venue":"coingecko","ID":"id"},{"venue":"cmc","ID":"id"}]}` 41 | elem, err := tickermetadata.CoreMetadataFromJSONString(elemJSON) 42 | require.NoError(t, err) 43 | 44 | require.Equal(t, tickermetadata.NewCoreMetadata( 45 | []tickermetadata.AggregatorID{ 46 | tickermetadata.NewAggregatorID("coingecko", "id"), 47 | tickermetadata.NewAggregatorID("cmc", "id"), 48 | }, 49 | ), elem) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /x/marketmap/types/tickermetadata/dydx.go: -------------------------------------------------------------------------------- 1 | package tickermetadata 2 | 3 | import "encoding/json" 4 | 5 | // DyDx is the Ticker.Metadata_JSON published to every Ticker in the x/marketmap module on dYdX. 6 | type DyDx struct { 7 | // ReferencePrice gives a spot price for that Ticker at the point in time when the ReferencePrice was updated. 8 | // You should _not_ use this for up-to-date/instantaneous spot pricing data since it is updated infrequently. 9 | // The price is scaled by Ticker.Decimals. 10 | ReferencePrice uint64 `json:"reference_price"` 11 | // Liquidity gives a _rough_ estimate of the amount of liquidity in the Providers for a given Market. 12 | // It is _not_ updated in coordination with spot prices and only gives rough order of magnitude accuracy at the time 13 | // which the update for it is published. 14 | // The liquidity value stored here is USD denominated. 15 | Liquidity uint64 `json:"liquidity"` 16 | // AggregateIDs contains a list of AggregatorIDs associated with the ticker. 17 | // This field may not be populated if no aggregator currently indexes this Ticker. 18 | AggregateIDs []AggregatorID `json:"aggregate_ids"` 19 | } 20 | 21 | // NewDyDx returns a new DyDx instance. 22 | func NewDyDx(referencePrice, liquidity uint64, aggregateIDs []AggregatorID) DyDx { 23 | return DyDx{ 24 | ReferencePrice: referencePrice, 25 | Liquidity: liquidity, 26 | AggregateIDs: aggregateIDs, 27 | } 28 | } 29 | 30 | // MarshalDyDx returns the JSON byte encoding of the DyDx. 31 | func MarshalDyDx(m DyDx) ([]byte, error) { 32 | return json.Marshal(m) 33 | } 34 | 35 | // DyDxFromJSONString returns a DyDx instance from a JSON string. 36 | func DyDxFromJSONString(jsonString string) (DyDx, error) { 37 | var elem DyDx 38 | err := json.Unmarshal([]byte(jsonString), &elem) 39 | return elem, err 40 | } 41 | 42 | // DyDxFromJSONBytes returns a DyDx instance from JSON bytes. 43 | func DyDxFromJSONBytes(jsonBytes []byte) (DyDx, error) { 44 | var elem DyDx 45 | err := json.Unmarshal(jsonBytes, &elem) 46 | return elem, err 47 | } 48 | -------------------------------------------------------------------------------- /x/marketmap/types/tickermetadata/dydx_test.go: -------------------------------------------------------------------------------- 1 | package tickermetadata_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/skip-mev/connect/v2/x/marketmap/types/tickermetadata" 9 | ) 10 | 11 | func Test_UnmarshalDyDx(t *testing.T) { 12 | t.Run("can marshal and unmarshal the same struct and values", func(t *testing.T) { 13 | elem := tickermetadata.NewDyDx( 14 | 100, 15 | 1000, 16 | []tickermetadata.AggregatorID{ 17 | tickermetadata.NewAggregatorID("coingecko", "id"), 18 | tickermetadata.NewAggregatorID("cmc", "id"), 19 | }, 20 | ) 21 | 22 | bz, err := tickermetadata.MarshalDyDx(elem) 23 | require.NoError(t, err) 24 | 25 | elem2, err := tickermetadata.DyDxFromJSONBytes(bz) 26 | require.NoError(t, err) 27 | require.Equal(t, elem, elem2) 28 | }) 29 | 30 | t.Run("can marshal and unmarshal the same struct and values with empty AggregatorIDs", func(t *testing.T) { 31 | elem := tickermetadata.NewDyDx(100, 1000, nil) 32 | 33 | bz, err := tickermetadata.MarshalDyDx(elem) 34 | require.NoError(t, err) 35 | 36 | elem2, err := tickermetadata.DyDxFromJSONBytes(bz) 37 | require.NoError(t, err) 38 | require.Equal(t, elem, elem2) 39 | }) 40 | 41 | t.Run("can unmarshal a JSON string into a struct", func(t *testing.T) { 42 | elemJSON := `{"reference_price":100,"liquidity":1000,"aggregate_ids":[{"venue":"coingecko","ID":"id"},{"venue":"cmc","ID":"id"}]}` 43 | elem, err := tickermetadata.DyDxFromJSONString(elemJSON) 44 | require.NoError(t, err) 45 | 46 | require.Equal(t, tickermetadata.NewDyDx( 47 | 100, 48 | 1000, 49 | []tickermetadata.AggregatorID{ 50 | tickermetadata.NewAggregatorID("coingecko", "id"), 51 | tickermetadata.NewAggregatorID("cmc", "id"), 52 | }, 53 | ), elem) 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /x/marketmap/types/utils.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // ReadMarketMapFromFile reads a market map configuration from a file at the given path. 10 | func ReadMarketMapFromFile(path string) (MarketMap, error) { 11 | // Initialize the struct to hold the configuration 12 | var config MarketMap 13 | 14 | // Read the entire file at the given path 15 | data, err := os.ReadFile(path) 16 | if err != nil { 17 | return config, fmt.Errorf("error reading config file: %w", err) 18 | } 19 | 20 | // Unmarshal the JSON data into the config struct 21 | if err := json.Unmarshal(data, &config); err != nil { 22 | return config, fmt.Errorf("error unmarshalling config JSON: %w", err) 23 | } 24 | 25 | if err := config.ValidateBasic(); err != nil { 26 | return config, fmt.Errorf("error validating config: %w", err) 27 | } 28 | 29 | return config, nil 30 | } 31 | 32 | // WriteMarketMapToFile writes a market map configuration to a file at the given path. 33 | func WriteMarketMapToFile(config MarketMap, path string) error { 34 | f, err := os.Create(path) 35 | if err != nil { 36 | return err 37 | } 38 | defer f.Close() 39 | 40 | encoder := json.NewEncoder(f) 41 | encoder.SetIndent("", " ") 42 | if err := encoder.Encode(config); err != nil { 43 | return err 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /x/marketmap/types/validation_hooks.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // MarketValidationHook is a hook that is called for stateful validation of a market before 9 | // some keeper operation is performed on it. 10 | type MarketValidationHook func(ctx context.Context, market Market) error 11 | 12 | // MarketValidationHooks is a type alias for an array of MarketValidationHook. 13 | type MarketValidationHooks []MarketValidationHook 14 | 15 | // ValidateMarket calls all validation hooks for the given market. 16 | func (h MarketValidationHooks) ValidateMarket(ctx context.Context, market Market) error { 17 | for _, hook := range h { 18 | if err := hook(ctx, market); err != nil { 19 | return fmt.Errorf("failed validation hooks for market %s: %w", market.Ticker.String(), err) 20 | } 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // DefaultDeleteMarketValidationHooks returns the default DeleteMarketValidationHook as an array. 27 | func DefaultDeleteMarketValidationHooks() MarketValidationHooks { 28 | hooks := MarketValidationHooks{ 29 | DefaultDeleteMarketValidationHook(), 30 | } 31 | 32 | return hooks 33 | } 34 | 35 | // DefaultDeleteMarketValidationHook returns the default DeleteMarketValidationHook for x/marketmap. 36 | // This hook checks: 37 | // - if the given market is enabled - error 38 | // - if the given market is disabled - return nil. 39 | func DefaultDeleteMarketValidationHook() MarketValidationHook { 40 | return func(_ context.Context, market Market) error { 41 | if market.Ticker.Enabled { 42 | return fmt.Errorf("market is enabled - cannot be deleted") 43 | } 44 | 45 | return nil 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /x/marketmap/types/validation_hooks_test.go: -------------------------------------------------------------------------------- 1 | package types_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | connecttypes "github.com/skip-mev/connect/v2/pkg/types" 10 | "github.com/skip-mev/connect/v2/x/marketmap/types" 11 | ) 12 | 13 | func TestDefaultDeleteMarketValidationHooks_ValidateMarket(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | market types.Market 17 | wantErr bool 18 | }{ 19 | { 20 | name: "valid - disabled market", 21 | market: types.Market{ 22 | Ticker: types.Ticker{ 23 | CurrencyPair: connecttypes.CurrencyPair{ 24 | Base: "BTC", 25 | Quote: "USD", 26 | }, 27 | Decimals: 3, 28 | MinProviderCount: 3, 29 | Enabled: false, 30 | Metadata_JSON: "", 31 | }, 32 | }, 33 | wantErr: false, 34 | }, 35 | { 36 | name: "invalid - enabled market", 37 | market: types.Market{ 38 | Ticker: types.Ticker{ 39 | CurrencyPair: connecttypes.CurrencyPair{ 40 | Base: "BTC", 41 | Quote: "USD", 42 | }, 43 | Decimals: 3, 44 | MinProviderCount: 3, 45 | Enabled: true, 46 | Metadata_JSON: "", 47 | }, 48 | }, 49 | wantErr: true, 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | hooks := types.DefaultDeleteMarketValidationHooks() 55 | 56 | err := hooks.ValidateMarket(context.Background(), tt.market) 57 | if tt.wantErr { 58 | require.Error(t, err) 59 | return 60 | } 61 | require.NoError(t, err) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /x/oracle/keeper/abci.go: -------------------------------------------------------------------------------- 1 | package keeper 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // BeginBlocker is called at the beginning of every block. It resets the count of 8 | // removed currency pairs. 9 | func (k *Keeper) BeginBlocker(ctx context.Context) error { 10 | return k.numRemoves.Set(ctx, 0) 11 | } 12 | -------------------------------------------------------------------------------- /x/oracle/types/codec.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/cosmos/cosmos-sdk/codec" 5 | "github.com/cosmos/cosmos-sdk/codec/legacy" 6 | "github.com/cosmos/cosmos-sdk/codec/types" 7 | sdk "github.com/cosmos/cosmos-sdk/types" 8 | "github.com/cosmos/cosmos-sdk/types/msgservice" 9 | ) 10 | 11 | // RegisterLegacyAminoCodec registers the necessary x/oracle interfaces (messages) on the 12 | // cdc. These types are used for amino serialization. 13 | func RegisterLegacyAminoCodec(cdc *codec.LegacyAmino) { 14 | // register the MsgAddCurrencyPairs for amino serialization 15 | legacy.RegisterAminoMsg(cdc, &MsgAddCurrencyPairs{}, "connect/x/oracle/MsgAddCurrencyPairs") 16 | 17 | // register the MsgRemoveCurrencyPairs for amino serialization 18 | legacy.RegisterAminoMsg(cdc, &MsgRemoveCurrencyPairs{}, "connect/x/oracle/MsgRemoveCurrencyPairs") 19 | } 20 | 21 | // RegisterInterfaces registers the x/oracle messages + message service w/ the InterfaceRegistry (registry). 22 | func RegisterInterfaces(registry types.InterfaceRegistry) { 23 | // register the MsgAddCurrencyPairs as an implementation of sdk.Msg 24 | registry.RegisterImplementations((*sdk.Msg)(nil), 25 | &MsgAddCurrencyPairs{}, 26 | &MsgRemoveCurrencyPairs{}, 27 | ) 28 | 29 | // register the x/oracle message-service 30 | msgservice.RegisterMsgServiceDesc(registry, &_Msg_serviceDesc) 31 | } 32 | -------------------------------------------------------------------------------- /x/oracle/types/currency_pair_state.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // NewCurrencyPairState returns a new CurrencyPairState given an ID, nonce, and QuotePrice. 8 | func NewCurrencyPairState(id, nonce uint64, quotePrice *QuotePrice) CurrencyPairState { 9 | return CurrencyPairState{ 10 | Id: id, 11 | Nonce: nonce, 12 | Price: quotePrice, 13 | } 14 | } 15 | 16 | // ValidateBasic checks that the CurrencyPairState is valid, i.e. the nonce is zero if the QuotePrice is nil, and non-zero 17 | // otherwise. 18 | func (cps *CurrencyPairState) ValidateBasic() error { 19 | // check that the nonce is zero if the QuotePrice is nil 20 | if cps.Price == nil && cps.Nonce != 0 { 21 | return fmt.Errorf("invalid nonce, no price update but non-zero nonce: %v", cps.Nonce) 22 | } 23 | 24 | // check that the nonce is non-zero if the QuotePrice is non-nil 25 | if cps.Price != nil && cps.Nonce == 0 { 26 | return fmt.Errorf("invalid nonce, price update but zero nonce: %v", cps.Nonce) 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /x/oracle/types/currency_pair_state_test.go: -------------------------------------------------------------------------------- 1 | package types_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "cosmossdk.io/math" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/skip-mev/connect/v2/x/oracle/types" 10 | ) 11 | 12 | func TestCurrencyPairState(t *testing.T) { 13 | tcs := []struct { 14 | name string 15 | cps types.CurrencyPairState 16 | valid bool 17 | }{ 18 | { 19 | "non-zero nonce, and nil price - invalid", 20 | types.CurrencyPairState{ 21 | Nonce: 1, 22 | Price: nil, 23 | }, 24 | false, 25 | }, 26 | { 27 | "zero nonce, and non-nil price - invalid", 28 | types.CurrencyPairState{ 29 | Nonce: 0, 30 | Price: &types.QuotePrice{ 31 | Price: math.NewInt(1), 32 | }, 33 | }, 34 | false, 35 | }, 36 | { 37 | "zero nonce, and nil price - valid", 38 | types.CurrencyPairState{ 39 | Nonce: 0, 40 | Price: nil, 41 | }, 42 | true, 43 | }, 44 | { 45 | "non-zero nonce, and non-nil price - valid", 46 | types.CurrencyPairState{ 47 | Nonce: 1, 48 | Price: &types.QuotePrice{ 49 | Price: math.NewInt(1), 50 | }, 51 | }, 52 | true, 53 | }, 54 | } 55 | 56 | for _, tc := range tcs { 57 | t.Run(tc.name, func(t *testing.T) { 58 | require.Equal(t, tc.cps.ValidateBasic() == nil, tc.valid) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /x/oracle/types/errors.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | 6 | connecttypes "github.com/skip-mev/connect/v2/pkg/types" 7 | ) 8 | 9 | func NewCurrencyPairNotExistError(cp connecttypes.CurrencyPair) CurrencyPairNotExistError { 10 | return CurrencyPairNotExistError{cp.String()} 11 | } 12 | 13 | type CurrencyPairNotExistError struct { 14 | cp string 15 | } 16 | 17 | func (e CurrencyPairNotExistError) Error() string { 18 | return fmt.Sprintf("nonce is not stored for CurrencyPair: %s", e.cp) 19 | } 20 | 21 | func NewQuotePriceNotExistError(cp connecttypes.CurrencyPair) QuotePriceNotExistError { 22 | return QuotePriceNotExistError{cp.String()} 23 | } 24 | 25 | type QuotePriceNotExistError struct { 26 | cp string 27 | } 28 | 29 | func (e QuotePriceNotExistError) Error() string { 30 | return fmt.Sprintf("no price updates for CurrencyPair: %s", e.cp) 31 | } 32 | 33 | type CurrencyPairAlreadyExistsError struct { 34 | cp string 35 | } 36 | 37 | func NewCurrencyPairAlreadyExistsError(cp connecttypes.CurrencyPair) CurrencyPairAlreadyExistsError { 38 | return CurrencyPairAlreadyExistsError{cp.String()} 39 | } 40 | 41 | func (e CurrencyPairAlreadyExistsError) Error() string { 42 | return fmt.Sprintf("currency pair already exists: %s", e.cp) 43 | } 44 | -------------------------------------------------------------------------------- /x/oracle/types/expected_keepers.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/skip-mev/connect/v2/x/marketmap/types" 7 | ) 8 | 9 | // MarketMapKeeper is the expected keeper interface for the market map keeper. 10 | // 11 | //go:generate mockery --name MarketMapKeeper --output ./mocks/ --case underscore 12 | type MarketMapKeeper interface { 13 | GetMarket(ctx context.Context, tickerStr string) (types.Market, error) 14 | } 15 | -------------------------------------------------------------------------------- /x/oracle/types/keys.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "cosmossdk.io/collections" 5 | "cosmossdk.io/collections/codec" 6 | ) 7 | 8 | const ( 9 | // ModuleName is the name of module for external use. 10 | ModuleName = "oracle" 11 | // StoreKey is the top-level store key for the oracle module. 12 | StoreKey = ModuleName 13 | ) 14 | 15 | var ( 16 | // CurrencyPairKeyPrefix is the key-prefix under which currency-pair state is stored. 17 | CurrencyPairKeyPrefix = collections.NewPrefix(0) 18 | 19 | // CurrencyPairIDKeyPrefix is the key-prefix under which the next currency-pairID is stored. 20 | CurrencyPairIDKeyPrefix = collections.NewPrefix(1) 21 | 22 | // UniqueIndexCurrencyPairKeyPrefix is the key-prefix under which the unique index on 23 | // currency-pairs is stored. 24 | UniqueIndexCurrencyPairKeyPrefix = collections.NewPrefix(2) 25 | 26 | // IDIndexCurrencyPairKeyPrefix is the key-prefix under which a currency-pair index. 27 | // is stored. 28 | IDIndexCurrencyPairKeyPrefix = collections.NewPrefix(3) 29 | 30 | // NumRemovesKeyPrefix is the key-prefix under which the number of removed CPs is stored. 31 | NumRemovesKeyPrefix = collections.NewPrefix(4) 32 | 33 | // NumCPsKeyPrefix is the key-prefix under which the number CPs is stored. 34 | NumCPsKeyPrefix = collections.NewPrefix(5) 35 | 36 | // CounterCodec is the collections.KeyCodec value used for the counter values. 37 | CounterCodec = codec.KeyToValueCodec[uint64](codec.NewUint64Key[uint64]()) 38 | ) 39 | -------------------------------------------------------------------------------- /x/oracle/types/quote_price.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // QuotePriceWithNonce is a wrapper around the QuotePrice object which also contains a nonce. 8 | // The nonce is meant to represent the number of times that the QuotePrice has been updated for a given 9 | // CurrencyPair. 10 | type QuotePriceWithNonce struct { 11 | QuotePrice 12 | nonce uint64 13 | } 14 | 15 | func NewQuotePriceWithNonce(qp QuotePrice, nonce uint64) QuotePriceWithNonce { 16 | return QuotePriceWithNonce{ 17 | qp, 18 | nonce, 19 | } 20 | } 21 | 22 | // Nonce returns the nonce for a given QuotePriceWithNonce. 23 | func (q *QuotePriceWithNonce) Nonce() uint64 { 24 | return q.nonce 25 | } 26 | 27 | // ValidateBasic validates that the QuotePrice is valid, i.e. that the price is non-negative. 28 | func (qp *QuotePrice) ValidateBasic() error { 29 | // Check that the price is non-negative 30 | if qp.Price.IsNegative() { 31 | return fmt.Errorf("price cannot be negative: %s", qp.Price) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // ValidateBasic validates that the QuotePriceWithNonce is valid, i.e that the underlying QuotePrice is valid. 38 | func (q *QuotePriceWithNonce) ValidateBasic() error { 39 | return q.QuotePrice.ValidateBasic() 40 | } 41 | -------------------------------------------------------------------------------- /x/oracle/types/quote_price_test.go: -------------------------------------------------------------------------------- 1 | package types_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "cosmossdk.io/math" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/skip-mev/connect/v2/x/oracle/types" 12 | ) 13 | 14 | func TestQuotePrice(t *testing.T) { 15 | tcs := []struct { 16 | name string 17 | quotePrice types.QuotePrice 18 | err error 19 | }{ 20 | { 21 | "negative price", 22 | types.QuotePrice{ 23 | Price: math.NewInt(-1), 24 | BlockTimestamp: time.Now().UTC(), 25 | BlockHeight: 1, 26 | }, 27 | fmt.Errorf("price cannot be negative: %s", math.NewInt(-1)), 28 | }, 29 | { 30 | "zero price", 31 | types.QuotePrice{ 32 | Price: math.NewInt(0), 33 | BlockTimestamp: time.Now().UTC(), 34 | BlockHeight: 1, 35 | }, 36 | nil, 37 | }, 38 | { 39 | "positive price", 40 | types.QuotePrice{ 41 | Price: math.NewInt(1), 42 | BlockTimestamp: time.Now().UTC(), 43 | BlockHeight: 1, 44 | }, 45 | nil, 46 | }, 47 | } 48 | 49 | for _, tc := range tcs { 50 | t.Run(tc.name, func(t *testing.T) { 51 | err := tc.quotePrice.ValidateBasic() 52 | require.Equal(t, tc.err, err) 53 | }) 54 | } 55 | } 56 | --------------------------------------------------------------------------------