Skip to content

Commit 6b31c82

Browse files
Merge pull request #1764 from xchainjs/fix/oneclick-affiliate-fee
fix(aggregator): populate OneClick affiliateFee from appFees
2 parents 0b77252 + 204afbe commit 6b31c82

4 files changed

Lines changed: 160 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@xchainjs/xchain-aggregator': patch
3+
---
4+
5+
Fix OneClick estimateSwap to populate fees.affiliateFee from echoed quoteRequest.appFees (falling back to configured affiliateBps) instead of always returning 0

‎packages/xchain-aggregator/__tests__/oneclickProtocol.test.ts‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,129 @@ describe('OneClick protocol', () => {
284284
const parsed = JSON.parse(capturedBody!)
285285
expect(parsed.dry).toBe(true)
286286
})
287+
288+
it('should populate affiliateFee from echoed quoteRequest.appFees matching affiliate address', async () => {
289+
const affiliateAddress = 'bc1qydqk2n5wwm2ugg05tv482w8p42734gft0ssze8'
290+
protocol = new OneClickProtocol({
291+
affiliateAddress,
292+
affiliateBps: 30,
293+
})
294+
295+
let capturedBody: string | undefined
296+
mockFetch.mockImplementation((url: string, options?: RequestInit) => {
297+
if (url.includes('/v0/tokens')) {
298+
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockTokens) })
299+
}
300+
if (url.includes('/v0/quote')) {
301+
capturedBody = options?.body as string
302+
return Promise.resolve({
303+
ok: true,
304+
json: () =>
305+
Promise.resolve({
306+
quoteRequest: {
307+
appFees: [
308+
{ recipient: affiliateAddress, fee: 15 },
309+
{ recipient: '5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd', fee: 25 },
310+
],
311+
},
312+
quote: {
313+
depositAddress: 'bc1qfakedeposit',
314+
amountOut: '99000',
315+
timeEstimate: 600,
316+
},
317+
}),
318+
})
319+
}
320+
return Promise.resolve({ ok: false, status: 404 })
321+
})
322+
323+
// 1 ETH = 1e18 base units; echoed fee 15 bps → 1e18 * 15 / 10000 = 1.5e15
324+
const quote = await protocol.estimateSwap({
325+
fromAsset: AssetETH,
326+
destinationAsset: AssetBTC,
327+
amount: new CryptoAmount(assetToBase(assetAmount(1, 18)), AssetETH),
328+
fromAddress: '0xSender',
329+
destinationAddress: 'bc1qRecipient',
330+
})
331+
332+
const parsed = JSON.parse(capturedBody!)
333+
expect(parsed.appFees).toEqual([{ recipient: affiliateAddress, fee: 30 }])
334+
expect(quote.fees.affiliateFee.baseAmount.amount().toString()).toBe('1500000000000000')
335+
expect(assetToString(quote.fees.affiliateFee.asset)).toBe('ETH.ETH')
336+
expect(quote.fees.affiliateFee.baseAmount.decimal).toBe(18)
337+
})
338+
339+
it('should fall back to configured affiliateBps when quoteRequest.appFees is absent', async () => {
340+
const affiliateAddress = 'bc1qydqk2n5wwm2ugg05tv482w8p42734gft0ssze8'
341+
protocol = new OneClickProtocol({
342+
affiliateAddress,
343+
affiliateBps: 30,
344+
})
345+
346+
mockFetch.mockImplementation((url: string) => {
347+
if (url.includes('/v0/tokens')) {
348+
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockTokens) })
349+
}
350+
if (url.includes('/v0/quote')) {
351+
return Promise.resolve({
352+
ok: true,
353+
json: () =>
354+
Promise.resolve({
355+
quote: {
356+
depositAddress: 'bc1qfakedeposit',
357+
amountOut: '99000',
358+
timeEstimate: 600,
359+
},
360+
}),
361+
})
362+
}
363+
return Promise.resolve({ ok: false, status: 404 })
364+
})
365+
366+
// 1 BTC = 1e8 sats; 30 bps → 1e8 * 30 / 10000 = 300000
367+
const quote = await protocol.estimateSwap({
368+
fromAsset: AssetBTC,
369+
destinationAsset: AssetETH,
370+
amount: new CryptoAmount(assetToBase(assetAmount(1, 8)), AssetBTC),
371+
fromAddress: 'bc1qSender',
372+
destinationAddress: '0xRecipient',
373+
})
374+
375+
expect(quote.fees.affiliateFee.baseAmount.amount().toString()).toBe('300000')
376+
expect(assetToString(quote.fees.affiliateFee.asset)).toBe('BTC.BTC')
377+
})
378+
379+
it('should leave affiliateFee at 0 when no affiliate is configured', async () => {
380+
mockFetch.mockImplementation((url: string) => {
381+
if (url.includes('/v0/tokens')) {
382+
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockTokens) })
383+
}
384+
if (url.includes('/v0/quote')) {
385+
return Promise.resolve({
386+
ok: true,
387+
json: () =>
388+
Promise.resolve({
389+
quote: {
390+
depositAddress: 'bc1qfakedeposit',
391+
amountOut: '99000',
392+
timeEstimate: 600,
393+
},
394+
}),
395+
})
396+
}
397+
return Promise.resolve({ ok: false, status: 404 })
398+
})
399+
400+
const quote = await protocol.estimateSwap({
401+
fromAsset: AssetETH,
402+
destinationAsset: AssetBTC,
403+
amount: new CryptoAmount(assetToBase(assetAmount(1, 18)), AssetETH),
404+
fromAddress: '0xSender',
405+
destinationAddress: 'bc1qRecipient',
406+
})
407+
408+
expect(quote.fees.affiliateFee.baseAmount.amount().toString()).toBe('0')
409+
})
287410
})
288411

289412
describe('doSwap', () => {

‎packages/xchain-aggregator/src/protocols/oneclick/oneclickProtocol.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from '../../types'
2424

2525
import { OneClickApi } from './api'
26-
import { CompatibleAsset, OneClickToken } from './types'
26+
import { CompatibleAsset, OneClickQuoteResponse, OneClickToken } from './types'
2727
import { findOneClickToken, oneClickBlockchainToXChain } from './utils'
2828

2929
export class OneClickProtocol implements IProtocol {
@@ -123,7 +123,7 @@ export class OneClickProtocol implements IProtocol {
123123
slipBasisPoints: 0,
124124
fees: {
125125
asset: params.fromAsset,
126-
affiliateFee: new CryptoAmount(baseAmount(0), params.fromAsset),
126+
affiliateFee: this.buildAffiliateFee(params, resp),
127127
outboundFee: new CryptoAmount(baseAmount(0), params.destinationAsset),
128128
},
129129
}
@@ -132,6 +132,28 @@ export class OneClickProtocol implements IProtocol {
132132
}
133133
}
134134

135+
/**
136+
* Resolve affiliate bps from echoed quoteRequest.appFees (matching our recipient),
137+
* falling back to the configured affiliateBps when appFees were sent.
138+
* Fee is charged from the input asset: amountIn * bps / 10000.
139+
*/
140+
private resolveAffiliateFeeBps(resp: OneClickQuoteResponse): number | undefined {
141+
const echoed = resp.quoteRequest?.appFees?.find((fee) => fee.recipient === this.affiliateAddress)
142+
if (echoed != null && Number.isFinite(echoed.fee)) return echoed.fee
143+
if (this.affiliateAddress && this.affiliateBps) return this.affiliateBps
144+
return undefined
145+
}
146+
147+
private buildAffiliateFee(params: QuoteSwapParams, resp: OneClickQuoteResponse): CryptoAmount {
148+
const decimals = params.amount.baseAmount.decimal
149+
const feeBps = this.resolveAffiliateFeeBps(resp)
150+
if (!feeBps) {
151+
return new CryptoAmount(baseAmount(0, decimals), params.fromAsset)
152+
}
153+
const feeAmount = params.amount.baseAmount.amount().multipliedBy(feeBps).dividedToIntegerBy(10000)
154+
return new CryptoAmount(baseAmount(feeAmount, decimals), params.fromAsset)
155+
}
156+
135157
public async doSwap(params: QuoteSwapParams): Promise<TxSubmitted> {
136158
const quoteSwap = await this.estimateSwap(params)
137159
if (!quoteSwap.canSwap) {

‎packages/xchain-aggregator/src/protocols/oneclick/types.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export type OneClickToken = {
1212
priceUpdatedAt?: string
1313
}
1414

15+
export type OneClickAppFee = {
16+
recipient: string
17+
fee: number
18+
}
19+
1520
export type OneClickQuoteRequest = {
1621
dry?: boolean
1722
swapType: 'EXACT_INPUT'
@@ -25,7 +30,7 @@ export type OneClickQuoteRequest = {
2530
recipient: string
2631
slippageTolerance?: number
2732
deadline?: string
28-
appFees?: { recipient: string; fee: number }[]
33+
appFees?: OneClickAppFee[]
2934
}
3035

3136
export type OneClickQuoteInner = {
@@ -37,6 +42,8 @@ export type OneClickQuoteInner = {
3742

3843
export type OneClickQuoteResponse = {
3944
quote: OneClickQuoteInner
45+
/** Echo of the request; used to resolve applied appFees after server-side splits. */
46+
quoteRequest?: Pick<OneClickQuoteRequest, 'appFees'>
4047
error?: string
4148
message?: string
4249
statusCode?: number

0 commit comments

Comments
 (0)