Skip to content

Commit 3fb8bf8

Browse files
authored
Merge pull request #1102 from oasisprotocol/ptrus/feature/evm-neby-prices
runtime/evm_tokens: Fetch Neby price for tokens
2 parents 1e639a4 + f0e319f commit 3fb8bf8

File tree

23 files changed

+393
-39
lines changed

23 files changed

+393
-39
lines changed

.changelog/1102.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
runtime/evm_tokens: Fetch Neby price for tokens

.github/workflows/ci-test.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ jobs:
135135

136136
test-e2e-regression:
137137
env:
138-
E2E_REGRESSION_ARTIFACTS_VERSION: 2025-05-30
138+
E2E_REGRESSION_ARTIFACTS_VERSION: 2025-07-21-2
139139
strategy:
140140
matrix:
141141
suite:
@@ -174,12 +174,13 @@ jobs:
174174
run: |
175175
make postgres
176176
177-
- name: Block access to Sourcify
178-
# Sourcify should not need to be available in CI; we run everything from caches.
177+
- name: Block access to Sourcify and Neby Graph API
178+
# These services should not be available in CI; we run everything from caches.
179179
# Enforce unavailability to keep tests confidently reproducible, and to catch
180-
# any bugs that might cause CI to accidentally use Sourcify.
180+
# any bugs that might cause CI to accidentally use live external services.
181181
run: |
182182
sudo bash -c 'echo 0.0.0.0 sourcify.dev | tee -a /etc/hosts'
183+
sudo bash -c 'echo 0.0.0.0 graph.api.neby.exchange | tee -a /etc/hosts'
183184
184185
- name: Run e2e regression tests
185186
run : |

.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ linters:
3131
- github.com/oasisprotocol/oasis-sdk
3232
- github.com/oasisprotocol/metadata-registry-tools
3333
- github.com/akrylysov/pogreb
34+
- github.com/cockroachdb/apd
3435
- github.com/dgraph-io/ristretto
3536
- github.com/ethereum/go-ethereum
3637
- github.com/go-chi/chi/v5

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ E2E_REGRESSION_SUITES_NO_LINKS := eden_testnet_2025 eden_2025 eden damask
6565
# make E2E_REGRESSION_SUITES='suite1 suite2' test-e2e-regression
6666
E2E_REGRESSION_SUITES := $(E2E_REGRESSION_SUITES_NO_LINKS) edenfast
6767

68-
E2E_REGRESSION_ARTIFACTS_VERSION = 2025-05-30
68+
E2E_REGRESSION_ARTIFACTS_VERSION = 2025-07-21-2
6969

7070
upload-e2e-regression-caches:
7171
for suite in $(E2E_REGRESSION_SUITES); do \
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Package nebyprices implements the Neby prices analyzer.
2+
package nebyprices
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"time"
12+
13+
ethCommon "github.com/ethereum/go-ethereum/common"
14+
15+
"github.com/oasisprotocol/nexus/analyzer"
16+
"github.com/oasisprotocol/nexus/analyzer/httpmisc"
17+
"github.com/oasisprotocol/nexus/analyzer/item"
18+
"github.com/oasisprotocol/nexus/common"
19+
"github.com/oasisprotocol/nexus/config"
20+
"github.com/oasisprotocol/nexus/log"
21+
"github.com/oasisprotocol/nexus/storage"
22+
)
23+
24+
const (
25+
analyzerPrefix = "neby_prices_"
26+
27+
defaultNebyEndpoint = "https://graph.api.neby.exchange/dex"
28+
)
29+
30+
type processor struct {
31+
runtime common.Runtime
32+
target storage.TargetStorage
33+
logger *log.Logger
34+
35+
client *http.Client
36+
37+
endpoint string
38+
}
39+
40+
var _ item.ItemProcessor[struct{}] = (*processor)(nil)
41+
42+
func NewAnalyzer(
43+
runtime common.Runtime,
44+
cfg *config.NebyPricesConfig,
45+
target storage.TargetStorage,
46+
logger *log.Logger,
47+
) (analyzer.Analyzer, error) {
48+
logger = logger.With("analyzer", analyzerPrefix+runtime)
49+
50+
logger.Info("Starting analyzer")
51+
52+
endpoint := cfg.Endpoint
53+
if endpoint == "" {
54+
endpoint = defaultNebyEndpoint
55+
}
56+
57+
if cfg.Interval == 0 {
58+
cfg.Interval = 15 * time.Minute
59+
}
60+
p := &processor{
61+
runtime: runtime,
62+
target: target,
63+
logger: logger,
64+
client: &http.Client{Timeout: 10 * time.Second},
65+
endpoint: endpoint,
66+
}
67+
68+
return item.NewAnalyzer(
69+
analyzerPrefix+string(runtime),
70+
cfg.ItemBasedAnalyzerConfig,
71+
p,
72+
target,
73+
logger,
74+
)
75+
}
76+
77+
func (p *processor) GetItems(ctx context.Context, limit uint64) ([]struct{}, error) {
78+
return []struct{}{{}}, nil
79+
}
80+
81+
func (p *processor) ProcessItem(ctx context.Context, batch *storage.QueryBatch, item struct{}) error {
82+
p.logger.Debug("fetching Neby token prices", "endpoint", p.endpoint)
83+
result, err := p.fetchNebyTokenPrices(ctx, p.endpoint)
84+
if err != nil {
85+
return fmt.Errorf("failed to fetch Neby token prices: %w", err)
86+
}
87+
p.logger.Debug("fetched Neby token prices", "count", len(result.Data.Tokens), "result", result)
88+
89+
// Store the prices in the database.
90+
for _, token := range result.Data.Tokens {
91+
ethAddress := ethCommon.HexToAddress(token.ID)
92+
batch.Queue(
93+
tokenNebyDerivedPriceUpsert,
94+
p.runtime,
95+
ethAddress[:],
96+
token.DerivedETH,
97+
)
98+
}
99+
100+
return nil
101+
}
102+
103+
type graphTokensResponse struct {
104+
Data struct {
105+
Tokens []struct {
106+
ID string `json:"id"`
107+
DerivedETH string `json:"derivedETH"`
108+
Name string `json:"name"`
109+
} `json:"tokens"`
110+
} `json:"data"`
111+
}
112+
113+
func (p *processor) fetchNebyTokenPrices(ctx context.Context, endpoint string) (*graphTokensResponse, error) {
114+
query := `
115+
{
116+
tokens(first: 1000) {
117+
id
118+
derivedETH
119+
name
120+
}
121+
}`
122+
body, _ := json.Marshal(map[string]string{
123+
"query": query,
124+
})
125+
126+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
127+
if err != nil {
128+
return nil, fmt.Errorf("failed to create request: %w", err)
129+
}
130+
req.Header.Set("Content-Type", "application/json")
131+
resp, err := p.client.Do(req)
132+
if err != nil {
133+
return nil, fmt.Errorf("failed to send request: %w", err)
134+
}
135+
if err := httpmisc.ResponseOK(resp); err != nil {
136+
return nil, fmt.Errorf("response error: %w", err)
137+
}
138+
defer resp.Body.Close()
139+
140+
respBody, err := io.ReadAll(resp.Body)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to read response body: %w", err)
143+
}
144+
145+
var result graphTokensResponse
146+
if err := json.Unmarshal(respBody, &result); err != nil {
147+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
148+
}
149+
150+
return &result, nil
151+
}
152+
153+
func (p *processor) QueueLength(ctx context.Context) (int, error) {
154+
return 0, nil
155+
}

analyzer/neby_prices/queries.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package nebyprices
2+
3+
// nolint: gosec // G101: Potential hardcoded credentials.
4+
const tokenNebyDerivedPriceUpsert = `
5+
INSERT INTO chain.evm_tokens (runtime, token_address, neby_derived_price)
6+
SELECT
7+
$1::runtime, derive_oasis_addr($2), $3
8+
WHERE
9+
derive_oasis_addr($2) IS NOT NULL
10+
ON CONFLICT (runtime, token_address) DO UPDATE
11+
SET
12+
neby_derived_price = excluded.neby_derived_price`

analyzer/pubclient/pub_client.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package pubclient
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"net"
@@ -91,3 +92,12 @@ var client = &http.Client{
9192
func GetWithContext(ctx context.Context, url string) (*http.Response, error) {
9293
return httpmisc.GetWithContextWithClient(ctx, client, url)
9394
}
95+
96+
func PostWithContext(ctx context.Context, url string, bodyType string, body []byte) (*http.Response, error) {
97+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
98+
if err != nil {
99+
return nil, err
100+
}
101+
req.Header.Set("Content-Type", bodyType)
102+
return client.Do(req)
103+
}

api/spec/v1.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,15 @@ components:
16691669
example: "1234567890123456789012"
16701670
x-go-type: common.BigInt
16711671
x-go-type-import: { name: common, path: "github.com/oasisprotocol/nexus/common" }
1672+
TextBigDecimal:
1673+
type: string
1674+
pattern: '^-?[0-9]+(\.[0-9]+)?$'
1675+
format: decimal # Informative only; not used by Go codegen
1676+
example: "12345.678901234567890123"
1677+
x-go-type: common.BigDecimal
1678+
x-go-type-import:
1679+
name: common
1680+
path: github.com/oasisprotocol/nexus/common
16721681
Address:
16731682
type: string
16741683
pattern: '^oasis1[a-z0-9]{40}$'
@@ -3646,6 +3655,15 @@ components:
36463655
total_supply:
36473656
allOf: [$ref: '#/components/schemas/TextBigInt']
36483657
description: The total number of base units available.
3658+
neby_derived_price:
3659+
allOf: [$ref: '#/components/schemas/TextBigDecimal']
3660+
description: |
3661+
The estimated price of one base unit of this token, expressed in the native denomination of the runtime.
3662+
3663+
This value is sourced from the Neby GraphQL API and reflects the token's `derivedETH` price as computed by the subgraph indexing the Neby DEX.
3664+
It is available only for tokens listed and tradable on the Neby exchange.
3665+
3666+
May be zero if the subgraph could not determine a valid price route to the native token unit.
36493667
num_transfers:
36503668
type: integer
36513669
format: int64

cmd/analyzer/analyzer.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/oasisprotocol/nexus/analyzer/evmtokens"
2929
"github.com/oasisprotocol/nexus/analyzer/evmverifier"
3030
"github.com/oasisprotocol/nexus/analyzer/metadata_registry"
31+
nebyprices "github.com/oasisprotocol/nexus/analyzer/neby_prices"
3132
nodestats "github.com/oasisprotocol/nexus/analyzer/node_stats"
3233
"github.com/oasisprotocol/nexus/analyzer/rofl"
3334
roflinstance "github.com/oasisprotocol/nexus/analyzer/rofl/instance_transactions"
@@ -619,6 +620,11 @@ func NewService(ctx context.Context, cfg *config.AnalysisConfig, logger *log.Log
619620
return evmabibackfill.NewAnalyzer(common.RuntimePontusxDev, cfg.Analyzers.PontusxDevAbi.ItemBasedAnalyzerConfig, dbClient, logger)
620621
})
621622
}
623+
if cfg.Analyzers.SapphireNebyPrices != nil {
624+
analyzers, err = addAnalyzer(analyzers, err, syncTagSapphire, func() (A, error) {
625+
return nebyprices.NewAnalyzer(common.RuntimeSapphire, cfg.Analyzers.SapphireNebyPrices, dbClient, logger)
626+
})
627+
}
622628
if cfg.Analyzers.MetadataRegistry != nil {
623629
analyzers, err = addAnalyzer(analyzers, err, "" /*syncTag*/, func() (A, error) {
624630
return metadata_registry.NewAnalyzer(*cfg.Analyzers.MetadataRegistry, dbClient, logger)

common/types.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"math/big"
99

10+
"github.com/cockroachdb/apd"
1011
"github.com/jackc/pgx/v5/pgtype"
1112
"github.com/oasisprotocol/oasis-core/go/common/quantity"
1213
staking "github.com/oasisprotocol/oasis-core/go/staking/api"
@@ -172,6 +173,71 @@ func NumericToBigInt(n pgtype.Numeric) (BigInt, error) {
172173
return BigInt{Int: *big0}, nil
173174
}
174175

176+
// Decimal is a wrapper around apd.Decimal to allow for custom JSON marshaling.
177+
type BigDecimal struct {
178+
apd.Decimal
179+
}
180+
181+
func NewBigDecimal(v string) (BigDecimal, error) {
182+
var d apd.Decimal
183+
if err := d.UnmarshalText([]byte(v)); err != nil {
184+
return BigDecimal{}, fmt.Errorf("invalid decimal string %q: %w", v, err)
185+
}
186+
return BigDecimal{d}, nil
187+
}
188+
189+
func (b BigDecimal) MarshalJSON() ([]byte, error) {
190+
t, err := b.MarshalText()
191+
if err != nil {
192+
return nil, err
193+
}
194+
return json.Marshal(string(t))
195+
}
196+
197+
func (b *BigDecimal) UnmarshalJSON(text []byte) error {
198+
var s string
199+
err := json.Unmarshal(text, &s)
200+
if err != nil {
201+
return err
202+
}
203+
return b.UnmarshalText([]byte(s))
204+
}
205+
206+
func (b BigDecimal) String() string {
207+
return b.Decimal.String()
208+
}
209+
210+
// Implement NumericValuer interface for BigInt.
211+
func (b BigDecimal) NumericValue() (pgtype.Numeric, error) {
212+
if b.Sign() == -1 {
213+
return pgtype.Numeric{}, fmt.Errorf("cannot convert negative decimal %s to Numeric", b.String())
214+
}
215+
return pgtype.Numeric{Int: &b.Coeff, Exp: b.Exponent, NaN: false, Valid: true, InfinityModifier: pgtype.Finite}, nil
216+
}
217+
218+
// Implement NumericDecoder interface for BigInt.
219+
func (b *BigDecimal) ScanNumeric(n pgtype.Numeric) error {
220+
if !n.Valid {
221+
return fmt.Errorf("NULL values can't be decoded. Scan into a **BigDecimal to handle NULLs")
222+
}
223+
if n.NaN {
224+
return fmt.Errorf("cannot scan NaN into *decimal.Decimal")
225+
}
226+
227+
if n.InfinityModifier != pgtype.Finite {
228+
return fmt.Errorf("cannot scan %v into *decimal.Decimal", n.InfinityModifier)
229+
}
230+
231+
b.Decimal = apd.Decimal{
232+
Coeff: *n.Int,
233+
Exponent: n.Exp,
234+
Negative: false,
235+
Form: apd.Finite,
236+
}
237+
238+
return nil
239+
}
240+
175241
func Ptr[T any](v T) *T {
176242
return &v
177243
}

0 commit comments

Comments
 (0)