Skip to content

Commit 8372279

Browse files
authored
feat: fastswap sweep redesign (#929)
* feat: fastswap miles sweep redesign foundation * feat: wire upfront fastswap miles awarding + cadence-based sweep loop * fix: treat Fuul 409 as success in submitToFuel to stop retries * fix: only use ETH input swaps to estimate sweep overhead
1 parent 0d7bff2 commit 8372279

17 files changed

Lines changed: 3381 additions & 273 deletions
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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

Comments
 (0)