Skip to content

feat(token-price-oracle): add multi-source price feeds#1002

Open
curryxbo wants to merge 3 commits into
mainfrom
feat/977-chainlink-token-price-oracle
Open

feat(token-price-oracle): add multi-source price feeds#1002
curryxbo wants to merge 3 commits into
mainfrom
feat/977-chainlink-token-price-oracle

Conversation

@curryxbo

@curryxbo curryxbo commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add off-chain price feed adapters for Chainlink AggregatorV3, Pyth Hermes, Bitget, Binance, and OKX under the existing token-price-oracle fallback framework.
  • Validate source freshness/health where available: Chainlink round checks, Pyth publish time and optional confidence BPS checks, and full batch coverage before accepting a fallback source.
  • Document per-source configuration and keep L2TokenRegistry unchanged; all external data sources are consumed by the off-chain updater before writing existing priceRatio values.

Test plan

  • cd token-price-oracle && go test ./...
  • cd token-price-oracle && go vet ./...
  • Public data smoke tests (temporary, not committed): Chainlink, Pyth Hermes, Binance, and OKX returned live BTC/ETH prices.

Closes #977

Co-authored-by: Cursor <cursoragent@cursor.com>
@curryxbo curryxbo requested a review from a team as a code owner June 22, 2026 11:41
@curryxbo curryxbo requested review from dylanCai9 and removed request for a team June 22, 2026 11:41
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Chainlink AggregatorV3 price feed client (ChainlinkPriceFeed) to the token-price-oracle service. New CLI flags, config fields, and a factory case wire Chainlink as a configurable priority feed with fallback to Bitget. FallbackPriceFeed.GetBatchTokenPrices is fixed to detect missing tokens by iterating requested IDs rather than the response map.

Changes

Chainlink Price Feed Integration

Layer / File(s) Summary
Config constants, struct fields, and CLI flags
token-price-oracle/config/config.go, token-price-oracle/flags/flags.go
Adds PriceFeedTypeChainlink constant, ChainlinkRPC/ChainlinkETHUSDFeed/ChainlinkMaxStaleness fields to Config, four new CLI flags with env-var wiring, Chainlink token-mapping parsing in LoadConfig, and feed-priority validation requiring RPC, ETH/USD feed, positive staleness, and non-empty token mappings.
ChainlinkPriceFeed struct and constructors
token-price-oracle/client/chainlink_feed.go
Defines ChainlinkPriceFeed holding an embedded AggregatorV3 ABI singleton, RPC caller, token feed address map, ETH/USD feed, staleness config, and an RW mutex. Adds NewChainlinkPriceFeed (RPC dial) and NewChainlinkPriceFeedWithCaller (caller injection) with full input validation and hex address parsing.
Price retrieval, round parsing, validation, and tests
token-price-oracle/client/chainlink_feed.go, token-price-oracle/client/chainlink_feed_test.go
Implements GetTokenPrice, GetBatchTokenPrices, fetchFeedPrice (AggregatorV3 contract bind, latestRoundData+decimals calls), parseChainlinkRoundData, parseChainlinkDecimals, validateChainlinkRound (staleness/future-time/positivity/round-consistency checks), chainlinkAnswerToFloat, and mustParseChainlinkAggregatorABI; table-driven tests cover round validation and answer-to-float conversion.
FallbackPriceFeed batch validation fix
token-price-oracle/client/price_feed.go
Changes GetBatchTokenPrices to iterate over requested tokenIDs and check each exists in the feed response map; missing tokens log a warning, set the invalid flag, and trigger fallback to the next feed.
Factory wiring for Chainlink feed
token-price-oracle/updater/factory.go
Adds PriceFeedTypeChainlink case in createSinglePriceFeed that validates token mappings, constructs a ChainlinkPriceFeed via client.NewChainlinkPriceFeed, and returns it with a "chainlink" label.
Documentation and environment configuration
token-price-oracle/README.md, token-price-oracle/env.example, token-price-oracle/docker-compose.yml, token-price-oracle/local.sh
Updates README with Chainlink feed description, new env-var tables, and project structure; adds Chainlink placeholder config to env.example, docker-compose.yml, and local.sh.

Sequence Diagram(s)

sequenceDiagram
    participant Updater
    participant FallbackPriceFeed
    participant ChainlinkPriceFeed
    participant AggregatorV3Contract
    participant BitgetClient

    rect rgba(100, 149, 237, 0.5)
        Note over FallbackPriceFeed,AggregatorV3Contract: Chainlink (primary)
        Updater->>FallbackPriceFeed: GetBatchTokenPrices(tokenIDs)
        FallbackPriceFeed->>ChainlinkPriceFeed: GetBatchTokenPrices(tokenIDs)
        ChainlinkPriceFeed->>AggregatorV3Contract: latestRoundData() ETH/USD feed
        AggregatorV3Contract-->>ChainlinkPriceFeed: roundData, decimals
        loop each tokenID
            ChainlinkPriceFeed->>AggregatorV3Contract: latestRoundData() token feed
            AggregatorV3Contract-->>ChainlinkPriceFeed: roundData, decimals
        end
        ChainlinkPriceFeed-->>FallbackPriceFeed: map[tokenID]*TokenPrice
    end

    rect rgba(255, 165, 0, 0.5)
        Note over FallbackPriceFeed,BitgetClient: Bitget (fallback on missing token or error)
        FallbackPriceFeed->>FallbackPriceFeed: iterate requested tokenIDs, detect missing
        FallbackPriceFeed->>BitgetClient: GetBatchTokenPrices(tokenIDs)
        BitgetClient-->>FallbackPriceFeed: map[tokenID]*TokenPrice
    end

    FallbackPriceFeed-->>Updater: map[tokenID]*TokenPrice
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • r3aker86
  • twcctop

Poem

🐇 Hippity-hop to the on-chain shop,
A Chainlink feed to make prices pop!
Round data checked, staleness denied,
Bitget waits patiently right by its side.
ETH/USD floats with decimal grace —
The oracle hops at a reliable pace! 🔗

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR successfully addresses Phase 1 objectives from #977: Chainlink integration with RPC support, per-token configuration, freshness validation via maxStaleness heartbeat, and baseline metrics via logging.
Out of Scope Changes check ✅ Passed All changes are directly related to Chainlink price feed integration. Documentation, configuration flags, factory methods, price validation, and test coverage are all in scope for this feature.
Title check ✅ Passed The title 'feat(token-price-oracle): add multi-source price feeds' aligns with the core objective of integrating Chainlink as a configurable price feed source to enable multi-source aggregation and fallback mechanisms, which is the primary focus of this PR.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/977-chainlink-token-price-oracle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
token-price-oracle/client/chainlink_feed_test.go (1)

63-69: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Single-case test with potential precision loss in TestChainlinkAnswerToFloat.

The test verifies only one conversion scenario (123456789000 → 1234.56789) and relies on Float64() conversion, which truncates to ~17 significant digits. The implementation uses big.Float.SetPrec(256) for high-precision arithmetic, but the test doesn't validate precision preservation or edge cases:

  • decimals = 0 (no scaling)
  • decimals = 18 (extreme precision, common for ERC-20 tokens)
  • Large answer values where Float64 conversion may lose precision
  • Answer = 1 with various decimal positions

Adding parameterized test cases for different decimal values would increase confidence in correct price scaling.

📋 Suggested parameterized test cases
func TestChainlinkAnswerToFloat(t *testing.T) {
	tests := []struct {
		name      string
		answer    *big.Int
		decimals  uint8
		expected  float64
	}{
		{
			name:     "standard 8 decimals",
			answer:   big.NewInt(123456789000),
			decimals: 8,
			expected: 1234.56789,
		},
		{
			name:     "no decimals",
			answer:   big.NewInt(2000),
			decimals: 0,
			expected: 2000,
		},
		{
			name:     "18 decimals (ERC-20 standard)",
			answer:   big.NewInt(1_000_000_000_000_000_000), // 1 token with 18 decimals
			decimals: 18,
			expected: 1.0,
		},
		{
			name:     "single unit",
			answer:   big.NewInt(1),
			decimals: 8,
			expected: 0.00000001,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			price := chainlinkAnswerToFloat(tt.answer, tt.decimals)
			got, _ := price.Float64()
			if got != tt.expected {
				t.Fatalf("chainlinkAnswerToFloat(%s, %d) = %v, want %v", 
					tt.answer.String(), tt.decimals, got, tt.expected)
			}
		})
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@token-price-oracle/client/chainlink_feed_test.go` around lines 63 - 69, The
TestChainlinkAnswerToFloat function only tests a single case and does not
validate the chainlinkAnswerToFloat implementation across edge cases and
different decimal positions. Refactor TestChainlinkAnswerToFloat into a
parameterized test by creating a test cases table with fields for test name,
answer value as *big.Int, decimals as uint8, and expected float64 result.
Iterate through the test cases using t.Run() and verify the
chainlinkAnswerToFloat function correctly handles scenarios including:
decimals=0 (no scaling), decimals=18 (ERC-20 standard), large answer values, and
answer=1 with various decimal positions. This ensures the high-precision
arithmetic in the implementation is properly validated across different scaling
scenarios.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@token-price-oracle/client/chainlink_feed_test.go`:
- Around line 9-61: The TestValidateChainlinkRound test is missing coverage for
critical validation paths in validateChainlinkRound: future timestamp
validation, nil parameter checks, and staleness boundary conditions. Add four
new test cases to the tests table: one for future updatedAt values beyond the
maxStaleness window, separate cases for nil values in each of the four input
parameters (answer, updatedAt, roundID, answeredInRound), and one for the exact
staleness boundary condition where updatedAt is exactly maxStaleness in the
past, with appropriate wantErr values for each.

In `@token-price-oracle/client/chainlink_feed.go`:
- Around line 243-245: The future timestamp validation in the condition check at
line 243 is too permissive because it allows timestamps up to now plus
maxStaleness in the future. Change the condition to reject any updatedAt
timestamp that is after the current time (now), rather than allowing it to be up
to maxStaleness into the future. Replace now.Add(maxStaleness) with just now in
the After() comparison to ensure future-dated timestamps are properly rejected.

In `@token-price-oracle/updater/factory.go`:
- Around line 118-121: The log.Info statement at line 120 logs the raw
ChainlinkRPC URL directly, which exposes potentially sensitive API keys or
authentication credentials. Create a helper function called redactRPCForLog that
parses the RPC URL and returns only the scheme and host portion (e.g.,
"https://example.com"), stripping out any credentials or path-based API keys.
Then update the log.Info call to use the redactRPCForLog function on
cfg.ChainlinkRPC before logging it in the "rpc" field.

---

Nitpick comments:
In `@token-price-oracle/client/chainlink_feed_test.go`:
- Around line 63-69: The TestChainlinkAnswerToFloat function only tests a single
case and does not validate the chainlinkAnswerToFloat implementation across edge
cases and different decimal positions. Refactor TestChainlinkAnswerToFloat into
a parameterized test by creating a test cases table with fields for test name,
answer value as *big.Int, decimals as uint8, and expected float64 result.
Iterate through the test cases using t.Run() and verify the
chainlinkAnswerToFloat function correctly handles scenarios including:
decimals=0 (no scaling), decimals=18 (ERC-20 standard), large answer values, and
answer=1 with various decimal positions. This ensures the high-precision
arithmetic in the implementation is properly validated across different scaling
scenarios.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 238ff072-6b3c-4840-af5f-4bf64cbaa274

📥 Commits

Reviewing files that changed from the base of the PR and between 99bc207 and 2e4be7d.

📒 Files selected for processing (10)
  • token-price-oracle/README.md
  • token-price-oracle/client/chainlink_feed.go
  • token-price-oracle/client/chainlink_feed_test.go
  • token-price-oracle/client/price_feed.go
  • token-price-oracle/config/config.go
  • token-price-oracle/docker-compose.yml
  • token-price-oracle/env.example
  • token-price-oracle/flags/flags.go
  • token-price-oracle/local.sh
  • token-price-oracle/updater/factory.go

Comment on lines +9 to +61
func TestValidateChainlinkRound(t *testing.T) {
now := time.Unix(1_700_000_000, 0)

tests := []struct {
name string
answer *big.Int
updatedAt *big.Int
roundID *big.Int
answeredInRound *big.Int
wantErr bool
}{
{
name: "valid",
answer: big.NewInt(2000_00000000),
updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()),
roundID: big.NewInt(10),
answeredInRound: big.NewInt(10),
},
{
name: "non-positive answer",
answer: big.NewInt(0),
updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()),
roundID: big.NewInt(10),
answeredInRound: big.NewInt(10),
wantErr: true,
},
{
name: "stale",
answer: big.NewInt(2000_00000000),
updatedAt: big.NewInt(now.Add(-2 * time.Hour).Unix()),
roundID: big.NewInt(10),
answeredInRound: big.NewInt(10),
wantErr: true,
},
{
name: "answered in old round",
answer: big.NewInt(2000_00000000),
updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()),
roundID: big.NewInt(10),
answeredInRound: big.NewInt(9),
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChainlinkRound(tt.answer, tt.updatedAt, tt.roundID, tt.answeredInRound, time.Hour, now)
if (err != nil) != tt.wantErr {
t.Fatalf("validateChainlinkRound() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Incomplete test coverage for validateChainlinkRound validation logic.

The table-driven test covers valid, non-positive answer, stale, and answered-in-old-round cases, but misses critical validation paths shown in the implementation (Context snippet 2):

  1. Future time validation (line 243 in Context snippet 2): updated.After(now.Add(maxStaleness)) — no test exercises the case where updatedAt is too far in the future.
  2. Nil value checks (lines 237-239): The function returns an error if any of the four inputs is nil, but the test never passes nil values.
  3. Staleness boundary: No test for the edge case where now.Sub(updated) == maxStaleness (exactly at the threshold).

These gaps reduce confidence in round validation, especially for malformed or edge-case data from Chainlink feeds.

📋 Suggested additional test cases
{
    name:            "future time",
    answer:          big.NewInt(2000_00000000),
    updatedAt:       big.NewInt(now.Add(2 * time.Hour).Unix()), // beyond maxStaleness into future
    roundID:         big.NewInt(10),
    answeredInRound: big.NewInt(10),
    wantErr:         true,
},
{
    name:            "nil answer",
    answer:          nil,
    updatedAt:       big.NewInt(now.Add(-5 * time.Minute).Unix()),
    roundID:         big.NewInt(10),
    answeredInRound: big.NewInt(10),
    wantErr:         true,
},
{
    name:            "nil updatedAt",
    answer:          big.NewInt(2000_00000000),
    updatedAt:       nil,
    roundID:         big.NewInt(10),
    answeredInRound: big.NewInt(10),
    wantErr:         true,
},
{
    name:            "exactly at staleness boundary",
    answer:          big.NewInt(2000_00000000),
    updatedAt:       big.NewInt(now.Add(-1 * time.Hour).Unix()), // exactly maxStaleness ago
    roundID:         big.NewInt(10),
    answeredInRound: big.NewInt(10),
    // Behavior depends on > vs >=; current impl uses >, so this should pass
    wantErr:         false,
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@token-price-oracle/client/chainlink_feed_test.go` around lines 9 - 61, The
TestValidateChainlinkRound test is missing coverage for critical validation
paths in validateChainlinkRound: future timestamp validation, nil parameter
checks, and staleness boundary conditions. Add four new test cases to the tests
table: one for future updatedAt values beyond the maxStaleness window, separate
cases for nil values in each of the four input parameters (answer, updatedAt,
roundID, answeredInRound), and one for the exact staleness boundary condition
where updatedAt is exactly maxStaleness in the past, with appropriate wantErr
values for each.

Comment on lines +243 to +245
if updated.After(now.Add(maxStaleness)) {
return fmt.Errorf("updatedAt %s is too far in the future", updated.UTC().Format(time.RFC3339))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Future timestamp validation is too permissive.

Line 243 currently allows updatedAt up to now + maxStaleness; with a 1h staleness window this accepts future-dated rounds that should be rejected.

Proposed fix
 func validateChainlinkRound(answer, updatedAt, roundID, answeredInRound *big.Int, maxStaleness time.Duration, now time.Time) error {
@@
 	updated := time.Unix(updatedAt.Int64(), 0)
-	if updated.After(now.Add(maxStaleness)) {
-		return fmt.Errorf("updatedAt %s is too far in the future", updated.UTC().Format(time.RFC3339))
+	const maxFutureSkew = 30 * time.Second
+	if updated.After(now.Add(maxFutureSkew)) {
+		return fmt.Errorf("updatedAt %s is too far in the future (max skew %s)", updated.UTC().Format(time.RFC3339), maxFutureSkew)
 	}
 	if now.Sub(updated) > maxStaleness {
 		return fmt.Errorf("price is stale: updatedAt=%s maxStaleness=%s", updated.UTC().Format(time.RFC3339), maxStaleness)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if updated.After(now.Add(maxStaleness)) {
return fmt.Errorf("updatedAt %s is too far in the future", updated.UTC().Format(time.RFC3339))
}
updated := time.Unix(updatedAt.Int64(), 0)
const maxFutureSkew = 30 * time.Second
if updated.After(now.Add(maxFutureSkew)) {
return fmt.Errorf("updatedAt %s is too far in the future (max skew %s)", updated.UTC().Format(time.RFC3339), maxFutureSkew)
}
if now.Sub(updated) > maxStaleness {
return fmt.Errorf("price is stale: updatedAt=%s maxStaleness=%s", updated.UTC().Format(time.RFC3339), maxStaleness)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@token-price-oracle/client/chainlink_feed.go` around lines 243 - 245, The
future timestamp validation in the condition check at line 243 is too permissive
because it allows timestamps up to now plus maxStaleness in the future. Change
the condition to reject any updatedAt timestamp that is after the current time
(now), rather than allowing it to be up to maxStaleness into the future. Replace
now.Add(maxStaleness) with just now in the After() comparison to ensure
future-dated timestamps are properly rejected.

Comment on lines +118 to +121
log.Info("Chainlink price feed created",
"type", "chainlink",
"rpc", cfg.ChainlinkRPC,
"eth_usd_feed", cfg.ChainlinkETHUSDFeed.Hex(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not log the raw Chainlink RPC URL.

Line 120 logs cfg.ChainlinkRPC directly. RPC URLs commonly include API keys/auth material, which can leak via logs.

🔒 Proposed fix
-		log.Info("Chainlink price feed created",
+		log.Info("Chainlink price feed created",
 			"type", "chainlink",
-			"rpc", cfg.ChainlinkRPC,
+			"rpc", redactRPCForLog(cfg.ChainlinkRPC),
 			"eth_usd_feed", cfg.ChainlinkETHUSDFeed.Hex(),
 			"max_staleness", cfg.ChainlinkMaxStaleness,
 			"mapping", mapping)
// Add near other private helpers in this file.
func redactRPCForLog(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return "<invalid_rpc_url>"
	}
	return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@token-price-oracle/updater/factory.go` around lines 118 - 121, The log.Info
statement at line 120 logs the raw ChainlinkRPC URL directly, which exposes
potentially sensitive API keys or authentication credentials. Create a helper
function called redactRPCForLog that parses the RPC URL and returns only the
scheme and host portion (e.g., "https://example.com"), stripping out any
credentials or path-based API keys. Then update the log.Info call to use the
redactRPCForLog function on cfg.ChainlinkRPC before logging it in the "rpc"
field.

Co-authored-by: Cursor <cursoragent@cursor.com>
@curryxbo curryxbo changed the title feat(token-price-oracle): add Chainlink price feed feat(token-price-oracle): add multi-source price feeds Jun 22, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Token Price Oracle enhancement: multi-source aggregation, freshness/heartbeat, anomaly detection

1 participant