|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "log/slog" |
| 7 | + "math/big" |
| 8 | + "strings" |
| 9 | + "sync" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/ethereum/go-ethereum" |
| 13 | + "github.com/ethereum/go-ethereum/accounts/abi" |
| 14 | + "github.com/ethereum/go-ethereum/common" |
| 15 | + "github.com/ethereum/go-ethereum/ethclient" |
| 16 | +) |
| 17 | + |
| 18 | +// chainlinkFeedRegistryAddr is the Chainlink Feed Registry on Ethereum |
| 19 | +// mainnet. A single contract that maps (base, quote) → underlying feed and |
| 20 | +// exposes latestRoundData / decimals directly. New pairs were frozen years |
| 21 | +// ago, so some legitimate tokens (PEPE, ARB, possibly others) revert; the |
| 22 | +// caller routes those rows to sweep-time pricing instead. |
| 23 | +const chainlinkFeedRegistryAddr = "0x47Fb2585D2C56Fe188D0E6ec628a38b74fCeeeDf" |
| 24 | + |
| 25 | +// chainlinkEthDenomination is the sentinel address Chainlink uses to |
| 26 | +// represent ETH on the quote side of a (base, quote) pair. |
| 27 | +const chainlinkEthDenomination = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" |
| 28 | + |
| 29 | +// priceOracleCacheTTL bounds how long a fetched Chainlink rate is reused. |
| 30 | +// Major Chainlink feeds heartbeat hourly and update on price-deviation |
| 31 | +// thresholds; 5 min is comfortably tighter than the heartbeat and well |
| 32 | +// within miles-grade precision. |
| 33 | +const priceOracleCacheTTL = 5 * time.Minute |
| 34 | + |
| 35 | +const chainlinkFeedRegistryABI = `[ |
| 36 | + {"inputs":[{"name":"base","type":"address"},{"name":"quote","type":"address"}], |
| 37 | + "name":"latestRoundData", |
| 38 | + "outputs":[{"name":"roundId","type":"uint80"},{"name":"answer","type":"int256"},{"name":"startedAt","type":"uint256"},{"name":"updatedAt","type":"uint256"},{"name":"answeredInRound","type":"uint80"}], |
| 39 | + "stateMutability":"view","type":"function"}, |
| 40 | + {"inputs":[{"name":"base","type":"address"},{"name":"quote","type":"address"}], |
| 41 | + "name":"decimals", |
| 42 | + "outputs":[{"name":"","type":"uint8"}], |
| 43 | + "stateMutability":"view","type":"function"} |
| 44 | +]` |
| 45 | + |
| 46 | +const erc20DecimalsABI = `[ |
| 47 | + {"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"stateMutability":"view","type":"function"} |
| 48 | +]` |
| 49 | + |
| 50 | +// priceOracle resolves the ETH-wei value of a fastswap surplus at miles-award |
| 51 | +// time. Three sources, picked per swap shape: |
| 52 | +// |
| 53 | +// - Output is ETH/WETH: handled upstream (surplus is already ETH wei). |
| 54 | +// - ETH/WETH input + whitelisted ERC20 output: event-derived from the |
| 55 | +// trade's executed rate (most accurate possible — it IS the realized |
| 56 | +// rate of this exact swap). |
| 57 | +// - ERC20 input + whitelisted ERC20 output: Chainlink Feed Registry. Reads |
| 58 | +// are out-of-band so flash-loan manipulation doesn't apply; the oracle |
| 59 | +// network itself is not pool-manipulable. |
| 60 | +// |
| 61 | +// Non-whitelisted output tokens are always deferred to sweep time. This is |
| 62 | +// the structural defense against the attacker-token attack: a malicious |
| 63 | +// actor who mints their own token and controls its on-chain liquidity |
| 64 | +// cannot extract upfront miles, because their token isn't in tokenConfigs. |
| 65 | +type priceOracle struct { |
| 66 | + client *ethclient.Client |
| 67 | + logger *slog.Logger |
| 68 | + weth common.Address |
| 69 | + |
| 70 | + registryAddr common.Address |
| 71 | + registryABI abi.ABI |
| 72 | + erc20ABI abi.ABI |
| 73 | + |
| 74 | + mu sync.RWMutex |
| 75 | + rateCache map[common.Address]rateEntry |
| 76 | + decimalsCache map[common.Address]uint8 |
| 77 | +} |
| 78 | + |
| 79 | +type rateEntry struct { |
| 80 | + answer *big.Int |
| 81 | + feedDecimals uint8 |
| 82 | + fetchedAt time.Time |
| 83 | +} |
| 84 | + |
| 85 | +func newPriceOracle(client *ethclient.Client, weth common.Address, logger *slog.Logger) (*priceOracle, error) { |
| 86 | + regABI, err := abi.JSON(strings.NewReader(chainlinkFeedRegistryABI)) |
| 87 | + if err != nil { |
| 88 | + return nil, fmt.Errorf("parse chainlink registry ABI: %w", err) |
| 89 | + } |
| 90 | + ercABI, err := abi.JSON(strings.NewReader(erc20DecimalsABI)) |
| 91 | + if err != nil { |
| 92 | + return nil, fmt.Errorf("parse erc20 decimals ABI: %w", err) |
| 93 | + } |
| 94 | + return &priceOracle{ |
| 95 | + client: client, |
| 96 | + logger: logger, |
| 97 | + weth: weth, |
| 98 | + registryAddr: common.HexToAddress(chainlinkFeedRegistryAddr), |
| 99 | + registryABI: regABI, |
| 100 | + erc20ABI: ercABI, |
| 101 | + rateCache: map[common.Address]rateEntry{}, |
| 102 | + decimalsCache: map[common.Address]uint8{}, |
| 103 | + }, nil |
| 104 | +} |
| 105 | + |
| 106 | +// PriceSurplusEth returns the ETH-wei value of an ERC20-output surplus. |
| 107 | +// |
| 108 | +// Returns (eth wei, eligible, source). When eligible is false the caller MUST |
| 109 | +// route the row to sweep-time pro-rata pricing instead — this is the |
| 110 | +// attacker-token defense and the no-Chainlink-feed fallback rolled into one. |
| 111 | +// |
| 112 | +// Source values for logging: |
| 113 | +// |
| 114 | +// "event_derived" ETH/WETH input + whitelisted output |
| 115 | +// "chainlink" ERC20 input + whitelisted output, Registry hit |
| 116 | +// "deferred:not_whitelisted" output token not in tokenConfigs |
| 117 | +// "deferred:invalid_event" event surplus + userAmtOut sum to zero |
| 118 | +// "deferred:no_chainlink" Registry call reverted or returned bad data |
| 119 | +// "deferred:no_token_decim" ERC20 decimals call failed |
| 120 | +func (o *priceOracle) PriceSurplusEth( |
| 121 | + ctx context.Context, |
| 122 | + inputToken, outputToken common.Address, |
| 123 | + inputAmt, userAmtOut, surplus *big.Int, |
| 124 | +) (*big.Int, bool, string) { |
| 125 | + if !isWhitelisted(outputToken) { |
| 126 | + return nil, false, "deferred:not_whitelisted" |
| 127 | + } |
| 128 | + |
| 129 | + if inputToken == zeroAddr || inputToken == o.weth { |
| 130 | + v := deriveEthInputSurplusEth(inputAmt, userAmtOut, surplus) |
| 131 | + if v == nil { |
| 132 | + return nil, false, "deferred:invalid_event" |
| 133 | + } |
| 134 | + return v, true, "event_derived" |
| 135 | + } |
| 136 | + |
| 137 | + rate, feedDecimals, ok := o.getChainlinkRate(ctx, outputToken) |
| 138 | + if !ok { |
| 139 | + return nil, false, "deferred:no_chainlink" |
| 140 | + } |
| 141 | + tokenDecimals, ok := o.getTokenDecimals(ctx, outputToken) |
| 142 | + if !ok { |
| 143 | + return nil, false, "deferred:no_token_decim" |
| 144 | + } |
| 145 | + return scaleChainlinkAnswer(surplus, rate, tokenDecimals, feedDecimals), true, "chainlink" |
| 146 | +} |
| 147 | + |
| 148 | +// deriveEthInputSurplusEth computes surplus_eth from event data alone when |
| 149 | +// the input was ETH or WETH (so inputAmt is denominated in ETH wei). |
| 150 | +// |
| 151 | +// surplus_eth = surplus × inputAmt / (userAmtOut + surplus) |
| 152 | +// |
| 153 | +// This IS the trade's executed exchange rate — the contract took inputAmt |
| 154 | +// wei from the user, Barter returned (userAmtOut + surplus) tokens. No |
| 155 | +// external oracle can be more accurate than the trade's own realized price. |
| 156 | +// Whitelist-gated to prevent attacker-controlled-pool manipulation. |
| 157 | +// |
| 158 | +// Returns nil for degenerate events where the divisor is non-positive. |
| 159 | +func deriveEthInputSurplusEth(inputAmt, userAmtOut, surplus *big.Int) *big.Int { |
| 160 | + if inputAmt == nil || userAmtOut == nil || surplus == nil { |
| 161 | + return nil |
| 162 | + } |
| 163 | + denom := new(big.Int).Add(userAmtOut, surplus) |
| 164 | + if denom.Sign() <= 0 { |
| 165 | + return nil |
| 166 | + } |
| 167 | + result := new(big.Int).Mul(surplus, inputAmt) |
| 168 | + result.Div(result, denom) |
| 169 | + return result |
| 170 | +} |
| 171 | + |
| 172 | +// scaleChainlinkAnswer converts a Chainlink rate into surplus_eth. |
| 173 | +// |
| 174 | +// surplus_eth_wei = surplus_raw × answer × 10^(18 - feed_decimals) / 10^token_decimals |
| 175 | +// |
| 176 | +// Combined into one divisor exponent (token_decimals + feed_decimals - 18) |
| 177 | +// which can be negative — handled both directions. |
| 178 | +func scaleChainlinkAnswer(surplus, answer *big.Int, tokenDecimals, feedDecimals uint8) *big.Int { |
| 179 | + result := new(big.Int).Mul(surplus, answer) |
| 180 | + expDiv := int(tokenDecimals) + int(feedDecimals) - 18 |
| 181 | + switch { |
| 182 | + case expDiv > 0: |
| 183 | + divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(expDiv)), nil) |
| 184 | + result.Div(result, divisor) |
| 185 | + case expDiv < 0: |
| 186 | + multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(-expDiv)), nil) |
| 187 | + result.Mul(result, multiplier) |
| 188 | + } |
| 189 | + return result |
| 190 | +} |
| 191 | + |
| 192 | +func (o *priceOracle) getChainlinkRate(ctx context.Context, token common.Address) (*big.Int, uint8, bool) { |
| 193 | + o.mu.RLock() |
| 194 | + if entry, ok := o.rateCache[token]; ok && time.Since(entry.fetchedAt) < priceOracleCacheTTL { |
| 195 | + o.mu.RUnlock() |
| 196 | + return entry.answer, entry.feedDecimals, true |
| 197 | + } |
| 198 | + o.mu.RUnlock() |
| 199 | + |
| 200 | + answer, decimals, err := o.fetchChainlinkRate(ctx, token) |
| 201 | + if err != nil { |
| 202 | + o.logger.Warn("chainlink registry lookup failed; deferring to sweep for this token", |
| 203 | + slog.String("token", token.Hex()), slog.Any("error", err)) |
| 204 | + return nil, 0, false |
| 205 | + } |
| 206 | + |
| 207 | + o.mu.Lock() |
| 208 | + o.rateCache[token] = rateEntry{answer: answer, feedDecimals: decimals, fetchedAt: time.Now()} |
| 209 | + o.mu.Unlock() |
| 210 | + return answer, decimals, true |
| 211 | +} |
| 212 | + |
| 213 | +func (o *priceOracle) fetchChainlinkRate(ctx context.Context, token common.Address) (*big.Int, uint8, error) { |
| 214 | + ethSentinel := common.HexToAddress(chainlinkEthDenomination) |
| 215 | + |
| 216 | + answer, err := o.callRegistryReturningBigInt(ctx, "latestRoundData", token, ethSentinel, 1) |
| 217 | + if err != nil { |
| 218 | + return nil, 0, err |
| 219 | + } |
| 220 | + if answer.Sign() <= 0 { |
| 221 | + return nil, 0, fmt.Errorf("non-positive chainlink answer: %s", answer.String()) |
| 222 | + } |
| 223 | + |
| 224 | + decimals, err := o.callRegistryReturningUint8(ctx, "decimals", token, ethSentinel) |
| 225 | + if err != nil { |
| 226 | + return nil, 0, err |
| 227 | + } |
| 228 | + return answer, decimals, nil |
| 229 | +} |
| 230 | + |
| 231 | +// callRegistryReturningBigInt invokes a registry method that returns one or |
| 232 | +// more values, extracting the *big.Int at outputIndex. |
| 233 | +func (o *priceOracle) callRegistryReturningBigInt( |
| 234 | + ctx context.Context, method string, base, quote common.Address, outputIndex int, |
| 235 | +) (*big.Int, error) { |
| 236 | + data, err := o.registryABI.Pack(method, base, quote) |
| 237 | + if err != nil { |
| 238 | + return nil, fmt.Errorf("pack %s: %w", method, err) |
| 239 | + } |
| 240 | + raw, err := o.client.CallContract(ctx, ethereum.CallMsg{To: &o.registryAddr, Data: data}, nil) |
| 241 | + if err != nil { |
| 242 | + return nil, fmt.Errorf("call %s: %w", method, err) |
| 243 | + } |
| 244 | + out, err := o.registryABI.Unpack(method, raw) |
| 245 | + if err != nil { |
| 246 | + return nil, fmt.Errorf("unpack %s: %w", method, err) |
| 247 | + } |
| 248 | + if len(out) <= outputIndex { |
| 249 | + return nil, fmt.Errorf("unexpected %s output length %d", method, len(out)) |
| 250 | + } |
| 251 | + v, ok := out[outputIndex].(*big.Int) |
| 252 | + if !ok { |
| 253 | + return nil, fmt.Errorf("%s output[%d] is %T, want *big.Int", method, outputIndex, out[outputIndex]) |
| 254 | + } |
| 255 | + return v, nil |
| 256 | +} |
| 257 | + |
| 258 | +func (o *priceOracle) callRegistryReturningUint8( |
| 259 | + ctx context.Context, method string, base, quote common.Address, |
| 260 | +) (uint8, error) { |
| 261 | + data, err := o.registryABI.Pack(method, base, quote) |
| 262 | + if err != nil { |
| 263 | + return 0, fmt.Errorf("pack %s: %w", method, err) |
| 264 | + } |
| 265 | + raw, err := o.client.CallContract(ctx, ethereum.CallMsg{To: &o.registryAddr, Data: data}, nil) |
| 266 | + if err != nil { |
| 267 | + return 0, fmt.Errorf("call %s: %w", method, err) |
| 268 | + } |
| 269 | + out, err := o.registryABI.Unpack(method, raw) |
| 270 | + if err != nil { |
| 271 | + return 0, fmt.Errorf("unpack %s: %w", method, err) |
| 272 | + } |
| 273 | + if len(out) < 1 { |
| 274 | + return 0, fmt.Errorf("empty %s output", method) |
| 275 | + } |
| 276 | + v, ok := out[0].(uint8) |
| 277 | + if !ok { |
| 278 | + return 0, fmt.Errorf("%s output is %T, want uint8", method, out[0]) |
| 279 | + } |
| 280 | + return v, nil |
| 281 | +} |
| 282 | + |
| 283 | +func (o *priceOracle) getTokenDecimals(ctx context.Context, token common.Address) (uint8, bool) { |
| 284 | + o.mu.RLock() |
| 285 | + if dec, ok := o.decimalsCache[token]; ok { |
| 286 | + o.mu.RUnlock() |
| 287 | + return dec, true |
| 288 | + } |
| 289 | + o.mu.RUnlock() |
| 290 | + |
| 291 | + data, err := o.erc20ABI.Pack("decimals") |
| 292 | + if err != nil { |
| 293 | + o.logger.Warn("erc20 decimals pack failed", slog.String("token", token.Hex()), slog.Any("error", err)) |
| 294 | + return 0, false |
| 295 | + } |
| 296 | + raw, err := o.client.CallContract(ctx, ethereum.CallMsg{To: &token, Data: data}, nil) |
| 297 | + if err != nil { |
| 298 | + o.logger.Warn("erc20 decimals call failed", slog.String("token", token.Hex()), slog.Any("error", err)) |
| 299 | + return 0, false |
| 300 | + } |
| 301 | + out, err := o.erc20ABI.Unpack("decimals", raw) |
| 302 | + if err != nil || len(out) < 1 { |
| 303 | + o.logger.Warn("erc20 decimals unpack failed", slog.String("token", token.Hex()), slog.Any("error", err)) |
| 304 | + return 0, false |
| 305 | + } |
| 306 | + dec, ok := out[0].(uint8) |
| 307 | + if !ok { |
| 308 | + o.logger.Warn("erc20 decimals not uint8", slog.String("token", token.Hex())) |
| 309 | + return 0, false |
| 310 | + } |
| 311 | + |
| 312 | + o.mu.Lock() |
| 313 | + o.decimalsCache[token] = dec |
| 314 | + o.mu.Unlock() |
| 315 | + return dec, true |
| 316 | +} |
0 commit comments