Skip to content

Commit c254b2f

Browse files
Merge pull request #38 from reserve-protocol/feat/rfq
Chore: claculate amountIn/amountOut/priceImpact/truePriceImpact locally
2 parents d716f84 + 27464fe commit c254b2f

12 files changed

Lines changed: 231 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## [2.7.1] - 2026-07-24
2+
3+
### Changed
4+
5+
- USD values (`amountInValue`/`amountOutValue`) and the derived price impact (`priceImpact`/`truePriceImpact`) are now computed uniformly for every quote source using Reserve API token prices, instead of trusting each provider's own valuation. Providers price with different methodologies (the native zapper vs the aggregators vs RFQ venues), so the displayed price impact used to jump when the winning source changed even for near-identical quotes. Provider-reported values remain as fallbacks when a Reserve price is unavailable; `truePriceImpact` keeps its dust-adjusted semantics.
6+
17
## [2.7.0] - 2026-07-22
28

39
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ Notes:
218218
- `cowswap` is enabled on all supported chains (Ethereum, Base, Arbitrum, and BSC); `pcsx` only on BSC.
219219
- The architecture is adapter-based (`RfqAdapter`) so more intent venues can be added without touching the pipeline.
220220

221+
USD values and price impact are computed uniformly across all sources from Reserve API token prices (each provider's own valuation is only a fallback when a Reserve price is missing), so the displayed impact doesn't jump when the winning source changes.
222+
221223
Provider availability per chain is controlled by the `PROVIDER_ENABLED` matrix exported from the package:
222224

223225
```ts

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@reserve-protocol/react-zapper",
3-
"version": "2.7.0",
3+
"version": "2.7.1",
44
"type": "module",
55
"packageManager": "pnpm@11.1.1",
66
"description": "React component for DTF minting with zap functionality",

src/components/zap-mint/buy/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const Buy = ({ mode = 'modal', disabled }: BuyProps) => {
9393
type: 'buy',
9494
inputValue,
9595
insufficientBalance,
96+
tokenInPrice: selectedTokenPrice,
97+
tokenInDecimals: selectedToken.decimals,
9698
tokenOutPrice: indexDTFPrice,
9799
tokenOutDecimals: indexDTF?.token.decimals ?? 18,
98100
})

src/components/zap-mint/sell/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const Sell = ({ mode = 'modal', sellOnly, disabled }: SellProps) => {
9393
type: 'sell',
9494
inputValue,
9595
insufficientBalance,
96+
tokenInPrice: indexDTFPrice,
97+
tokenInDecimals: indexDTF?.token.decimals ?? 18,
9698
tokenOutPrice: selectedTokenPrice,
9799
tokenOutDecimals: selectedToken.decimals,
98100
})

src/hooks/useZapSwapQuery.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ const useZapSwapQuery = ({
6565
type,
6666
inputValue,
6767
insufficientBalance,
68+
tokenInPrice,
69+
tokenInDecimals,
6870
tokenOutPrice,
6971
tokenOutDecimals,
7072
}: {
@@ -78,8 +80,11 @@ const useZapSwapQuery = ({
7880
type: 'buy' | 'sell'
7981
inputValue: number
8082
insufficientBalance: boolean
81-
// Client-side USD pricing for the output token — RFQ sources use it to fill
82-
// amountOutValue/priceImpact since their APIs don't price in USD.
83+
// Reserve prices for both sides of the trade: every quote's USD values and
84+
// price impact are computed from these (uniform across sources); the
85+
// provider-reported values only remain as fallbacks.
86+
tokenInPrice?: number | null
87+
tokenInDecimals?: number
8388
tokenOutPrice?: number | null
8489
tokenOutDecimals?: number
8590
}) => {
@@ -204,17 +209,22 @@ const useZapSwapQuery = ({
204209
functionName: 'allowance',
205210
args: [owner, spender],
206211
}),
207-
amountInValue: inputValue || null,
208-
tokenOutPrice: tokenOutPrice ?? null,
209-
tokenOutDecimals: tokenOutDecimals ?? null,
210212
}
211213
: undefined
212214

215+
const pricing = {
216+
tokenInPrice: tokenInPrice ?? null,
217+
tokenInDecimals: tokenInDecimals ?? 18,
218+
tokenOutPrice: tokenOutPrice ?? null,
219+
tokenOutDecimals: tokenOutDecimals ?? 18,
220+
}
221+
213222
const { selected } = await fetchBestZapQuote({
214223
providers: availableProviders,
215224
quoteSource,
216225
simulate,
217226
rfq,
227+
pricing,
218228
endpointParams: {
219229
chainId,
220230
tokenIn,

src/hooks/zap-quote-providers.ts

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Address } from 'viem'
2-
import type { ZapPayload, ZapResponse } from '../types/api'
1+
import { Address, formatUnits } from 'viem'
2+
import type { ZapPayload, ZapResponse, ZapResult } from '../types/api'
33
import {
44
generateSourceId,
55
type Source,
@@ -35,18 +35,72 @@ export type EndpointContext = Omit<ZapPayload, 'url'> & {
3535

3636
/**
3737
* Extra context RFQ adapters need beyond the endpoint params: a chain read
38-
* for the allowance check and client-side USD pricing (RFQ APIs don't price
39-
* in USD). When absent, RFQ providers are skipped.
38+
* for the allowance check. When absent, RFQ providers are skipped.
4039
*/
4140
export type RfqFetchContext = {
4241
readAllowance: (
4342
token: Address,
4443
owner: Address,
4544
spender: Address
4645
) => Promise<bigint>
47-
amountInValue: number | null
46+
}
47+
48+
/**
49+
* Reserve token prices used to value every quote uniformly. Providers price
50+
* with different methodologies, so their USD values (and thus the shown price
51+
* impact) jump when the winning source changes; valuing all quotes with the
52+
* same Reserve prices removes that noise. Provider values stay as fallbacks.
53+
*/
54+
export type QuotePricing = {
55+
tokenInPrice: number | null
56+
tokenInDecimals: number
4857
tokenOutPrice: number | null
49-
tokenOutDecimals: number | null
58+
tokenOutDecimals: number
59+
}
60+
61+
export const applyReservePricing = (
62+
result: ZapResult,
63+
pricing?: QuotePricing
64+
): ZapResult => {
65+
if (!pricing) return result
66+
67+
const amountInValue =
68+
pricing.tokenInPrice != null
69+
? pricing.tokenInPrice *
70+
Number(formatUnits(BigInt(result.amountIn || 0), pricing.tokenInDecimals))
71+
: result.amountInValue
72+
const amountOutValue =
73+
pricing.tokenOutPrice != null
74+
? pricing.tokenOutPrice *
75+
Number(
76+
formatUnits(BigInt(result.amountOut || 0), pricing.tokenOutDecimals)
77+
)
78+
: result.amountOutValue
79+
80+
// Impacts only make sense when both sides share the same price source —
81+
// with either Reserve price missing, keep the provider's own numbers.
82+
const bothPriced =
83+
pricing.tokenInPrice != null &&
84+
pricing.tokenOutPrice != null &&
85+
amountInValue != null &&
86+
amountInValue > 0 &&
87+
amountOutValue != null
88+
const priceImpact = bothPriced
89+
? ((amountInValue - amountOutValue) / amountInValue) * 100
90+
: result.priceImpact
91+
const truePriceImpact = bothPriced
92+
? ((amountInValue - amountOutValue - (result.dustValue ?? 0)) /
93+
amountInValue) *
94+
100
95+
: result.truePriceImpact
96+
97+
return {
98+
...result,
99+
amountInValue,
100+
amountOutValue,
101+
priceImpact,
102+
truePriceImpact,
103+
}
50104
}
51105

52106
export type FetchQuoteContext = {
@@ -69,6 +123,7 @@ export type FetchQuoteContext = {
69123
*/
70124
simulate?: SimulateQuote
71125
rfq?: RfqFetchContext
126+
pricing?: QuotePricing
72127
}
73128

74129
export type FetchQuoteResult = {
@@ -154,20 +209,27 @@ const fetchRfqOne = async (
154209
})
155210

156211
try {
157-
const result = await adapter.fetchQuote({
212+
const quote = await adapter.fetchQuote({
158213
chainId: endpointParams.chainId,
159214
account: endpointParams.signer,
160215
tokenIn: endpointParams.tokenIn,
161216
tokenOut: endpointParams.tokenOut,
162217
amountIn: endpointParams.amountIn,
163218
slippage: endpointParams.slippage,
164219
apiUrl: endpointParams.apiUrl,
165-
amountInValue: ctx.rfq.amountInValue,
166-
tokenOutPrice: ctx.rfq.tokenOutPrice,
167-
tokenOutDecimals: ctx.rfq.tokenOutDecimals,
168220
readAllowance: ctx.rfq.readAllowance,
169221
})
170222

223+
const result = applyReservePricing(
224+
{
225+
...quote,
226+
validUntil:
227+
normalizeValidUntil(quote.validUntil) ??
228+
Date.now() + DEFAULT_QUOTE_TTL,
229+
},
230+
ctx.pricing
231+
)
232+
171233
trackIndexDTFQuote({
172234
account: ctx.analytics.account,
173235
tokenIn: ctx.analytics.tokenIn,
@@ -186,12 +248,7 @@ const fetchRfqOne = async (
186248

187249
return {
188250
status: 'success',
189-
result: {
190-
...result,
191-
validUntil:
192-
normalizeValidUntil(result.validUntil) ??
193-
Date.now() + DEFAULT_QUOTE_TTL,
194-
},
251+
result,
195252
source: provider.id,
196253
endpoint,
197254
}
@@ -255,6 +312,18 @@ const fetchOne = async (
255312

256313
const data: ZapResponse = await response.json()
257314

315+
const result = data?.result
316+
? applyReservePricing(
317+
{
318+
...data.result,
319+
validUntil:
320+
normalizeValidUntil(data.result.validUntil ?? data.validUntil) ??
321+
Date.now() + DEFAULT_QUOTE_TTL,
322+
},
323+
ctx.pricing
324+
)
325+
: data?.result
326+
258327
if (data) {
259328
trackIndexDTFQuote({
260329
account: ctx.analytics.account,
@@ -265,10 +334,10 @@ const fetchOne = async (
265334
type: ctx.analytics.type,
266335
endpoint,
267336
status: data.status,
268-
amountInValue: data.result?.amountInValue,
269-
amountOutValue: data.result?.amountOutValue,
270-
dustValue: data.result?.dustValue,
271-
truePriceImpact: data.result?.truePriceImpact,
337+
amountInValue: result?.amountInValue,
338+
amountOutValue: result?.amountOutValue,
339+
dustValue: result?.dustValue,
340+
truePriceImpact: result?.truePriceImpact,
272341
source: provider.id,
273342
})
274343
}
@@ -279,14 +348,7 @@ const fetchOne = async (
279348

280349
return {
281350
...data,
282-
result: data.result
283-
? {
284-
...data.result,
285-
validUntil:
286-
normalizeValidUntil(data.result.validUntil ?? data.validUntil) ??
287-
Date.now() + DEFAULT_QUOTE_TTL,
288-
}
289-
: data.result,
351+
result,
290352
source: provider.id,
291353
endpoint,
292354
}

src/utils/rfq/cowswap.ts

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
encodeFunctionData,
2323
encodePacked,
2424
ethAddress,
25-
formatUnits,
2625
hashTypedData,
2726
keccak256,
2827
stringToBytes,
@@ -154,18 +153,6 @@ export const mapCowQuoteToZapResult = (
154153
const buyAmount = BigInt(quote.buyAmount)
155154
const minAmountOut = applySlippage(buyAmount, ctx.slippage)
156155

157-
const amountOutValue =
158-
ctx.tokenOutPrice != null && ctx.tokenOutDecimals != null
159-
? ctx.tokenOutPrice * Number(formatUnits(buyAmount, ctx.tokenOutDecimals))
160-
: null
161-
const amountInValue = ctx.amountInValue
162-
// USD values are client-estimated; when either is missing the impact is
163-
// unknown and reported as 0 (no high-impact warning for this source then).
164-
const priceImpact =
165-
amountInValue != null && amountOutValue != null && amountInValue > 0
166-
? ((amountInValue - amountOutValue) / amountInValue) * 100
167-
: 0
168-
169156
const rfq: CowRfqOrder = {
170157
adapter: 'cowswap',
171158
chainId: ctx.chainId,
@@ -179,13 +166,15 @@ export const mapCowQuoteToZapResult = (
179166
quoteId: response.id ?? null,
180167
}
181168

169+
// USD values and price impact are filled in centrally by the quote
170+
// pipeline (`applyReservePricing`) using Reserve prices.
182171
return {
183172
tokenIn: ctx.tokenIn,
184173
amountIn: ctx.amountIn,
185-
amountInValue,
174+
amountInValue: null,
186175
tokenOut: ctx.tokenOut,
187176
amountOut: quote.buyAmount,
188-
amountOutValue,
177+
amountOutValue: null,
189178
minAmountOut: minAmountOut.toString(),
190179
approvalAddress: (opts.flow === 'ethflow'
191180
? ETH_FLOW_ADDRESSES[ctx.chainId as SupportedChainId]
@@ -197,8 +186,8 @@ export const mapCowQuoteToZapResult = (
197186
dust: [],
198187
dustValue: null,
199188
gas: null,
200-
priceImpact,
201-
truePriceImpact: priceImpact,
189+
priceImpact: 0,
190+
truePriceImpact: 0,
202191
tx: null,
203192
validUntil: Date.parse(response.expiration) || null,
204193
rfq,

src/utils/rfq/pcsx.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ethAddress, formatUnits, type Address, type Hex } from 'viem'
1+
import { ethAddress, type Address, type Hex } from 'viem'
22
import type { ZapResult } from '../../types/api'
33
import { ChainId } from '../chains'
44
import { applySlippage } from './cowswap'
@@ -117,17 +117,6 @@ export const pcsxAdapter: RfqAdapter = {
117117
? BigInt(order.minAmountOut)
118118
: applySlippage(buyAmount, ctx.slippage)
119119

120-
const amountOutValue =
121-
ctx.tokenOutPrice != null && ctx.tokenOutDecimals != null
122-
? ctx.tokenOutPrice *
123-
Number(formatUnits(buyAmount, ctx.tokenOutDecimals))
124-
: null
125-
const amountInValue = ctx.amountInValue
126-
const priceImpact =
127-
amountInValue != null && amountOutValue != null && amountInValue > 0
128-
? ((amountInValue - amountOutValue) / amountInValue) * 100
129-
: 0
130-
131120
const rfq: PcsxRfqOrder = {
132121
adapter: 'pcsx',
133122
chainId: ctx.chainId,
@@ -138,22 +127,24 @@ export const pcsxAdapter: RfqAdapter = {
138127
quoteId: order.quoteId ?? null,
139128
}
140129

130+
// USD values and price impact are filled in centrally by the quote
131+
// pipeline (`applyReservePricing`) using Reserve prices.
141132
return {
142133
tokenIn: ctx.tokenIn,
143134
amountIn: ctx.amountIn,
144-
amountInValue,
135+
amountInValue: null,
145136
tokenOut: ctx.tokenOut,
146137
amountOut: order.amountOut,
147-
amountOutValue,
138+
amountOutValue: null,
148139
minAmountOut: minAmountOut.toString(),
149140
approvalAddress: permit2,
150141
approvalNeeded: allowance < BigInt(ctx.amountIn),
151142
insufficientFunds: false,
152143
dust: [],
153144
dustValue: null,
154145
gas: null,
155-
priceImpact,
156-
truePriceImpact: priceImpact,
146+
priceImpact: 0,
147+
truePriceImpact: 0,
157148
tx: null,
158149
validUntil: rfq.deadline,
159150
rfq,

0 commit comments

Comments
 (0)