feat(token-price-oracle): add multi-source price feeds#1002
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
📝 WalkthroughWalkthroughAdds a Chainlink AggregatorV3 price feed client ( ChangesChainlink Price Feed Integration
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
token-price-oracle/client/chainlink_feed_test.go (1)
63-69: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winSingle-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 usesbig.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
📒 Files selected for processing (10)
token-price-oracle/README.mdtoken-price-oracle/client/chainlink_feed.gotoken-price-oracle/client/chainlink_feed_test.gotoken-price-oracle/client/price_feed.gotoken-price-oracle/config/config.gotoken-price-oracle/docker-compose.ymltoken-price-oracle/env.exampletoken-price-oracle/flags/flags.gotoken-price-oracle/local.shtoken-price-oracle/updater/factory.go
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
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):
- Future time validation (line 243 in Context snippet 2):
updated.After(now.Add(maxStaleness))— no test exercises the case whereupdatedAtis too far in the future. - Nil value checks (lines 237-239): The function returns an error if any of the four inputs is
nil, but the test never passesnilvalues. - 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.
| if updated.After(now.Add(maxStaleness)) { | ||
| return fmt.Errorf("updatedAt %s is too far in the future", updated.UTC().Format(time.RFC3339)) | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| log.Info("Chainlink price feed created", | ||
| "type", "chainlink", | ||
| "rpc", cfg.ChainlinkRPC, | ||
| "eth_usd_feed", cfg.ChainlinkETHUSDFeed.Hex(), |
There was a problem hiding this comment.
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>
Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
L2TokenRegistryunchanged; all external data sources are consumed by the off-chain updater before writing existingpriceRatiovalues.Test plan
cd token-price-oracle && go test ./...cd token-price-oracle && go vet ./...Closes #977