@@ -22,6 +22,7 @@ import {
2222 type Hex ,
2323 http ,
2424 isAddress ,
25+ parseAbi ,
2526 parseAbiItem ,
2627 parseEventLogs ,
2728} from "viem" ;
@@ -118,6 +119,57 @@ const BSC_TOKEN_OPTIONS: DirectWalletTokenOption[] = [
118119 } ,
119120] ;
120121
122+ // Chainlink BNB/USD aggregator on BSC mainnet — 8-decimal answer, ~60s
123+ // heartbeat, 0.5% deviation. Override via CRYPTO_DIRECT_BSC_BNB_USD_FEED for
124+ // tests / private deployments.
125+ const CHAINLINK_BNB_USD_FEED = "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE" ;
126+ const CHAINLINK_AGGREGATOR_ABI = parseAbi ( [
127+ "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)" ,
128+ "function decimals() view returns (uint8)" ,
129+ ] ) ;
130+ // Reject the oracle if the last update is older than this. The feed normally
131+ // ticks every minute; an hour of staleness means the feed is unhealthy and
132+ // the price could be far from market. Locking a payment to a stale quote is
133+ // how exchanges get drained.
134+ const BNB_USD_MAX_AGE_SECONDS = 60 * 60 ;
135+
136+ async function bnbUsdQuote (
137+ env : Bindings ,
138+ ) : Promise < { priceUsd : Decimal ; updatedAt : number ; feedAddress : Hex } > {
139+ const feedOverride = envString ( env , "CRYPTO_DIRECT_BSC_BNB_USD_FEED" ) ;
140+ const feed = feedOverride ? getAddress ( feedOverride ) : getAddress ( CHAINLINK_BNB_USD_FEED ) ;
141+ const rpcUrl =
142+ envString ( env , "CRYPTO_DIRECT_BSC_RPC_URL" ) ??
143+ envString ( env , "BSC_RPC_URL" ) ??
144+ "https://bsc-dataseed.binance.org" ;
145+ const client = createPublicClient ( { chain : bsc , transport : http ( rpcUrl ) } ) ;
146+ const [ round , decimals ] = await Promise . all ( [
147+ client . readContract ( {
148+ address : feed ,
149+ abi : CHAINLINK_AGGREGATOR_ABI ,
150+ functionName : "latestRoundData" ,
151+ } ) ,
152+ client . readContract ( {
153+ address : feed ,
154+ abi : CHAINLINK_AGGREGATOR_ABI ,
155+ functionName : "decimals" ,
156+ } ) ,
157+ ] ) ;
158+ const answer = round [ 1 ] ;
159+ const updatedAt = Number ( round [ 3 ] ) ;
160+ if ( answer <= 0n ) {
161+ throw new Error ( "BNB/USD oracle returned a non-positive price" ) ;
162+ }
163+ const ageSeconds = Math . floor ( Date . now ( ) / 1000 ) - updatedAt ;
164+ if ( ageSeconds < 0 || ageSeconds > BNB_USD_MAX_AGE_SECONDS ) {
165+ throw new Error (
166+ `BNB/USD oracle is stale (last update ${ ageSeconds } s ago); refusing to quote` ,
167+ ) ;
168+ }
169+ const priceUsd = new Decimal ( answer . toString ( ) ) . div ( new Decimal ( 10 ) . pow ( Number ( decimals ) ) ) ;
170+ return { priceUsd, updatedAt, feedAddress : feed } ;
171+ }
172+
121173function resolveBscToken ( symbol : string | undefined ) : DirectWalletTokenOption {
122174 if ( ! symbol ) return BSC_TOKEN_OPTIONS [ 1 ] ; // default USDT
123175 const match = BSC_TOKEN_OPTIONS . find (
0 commit comments