@yoroi/swap
Version:
The Swap package of Yoroi SDK
324 lines (315 loc) • 12.1 kB
JavaScript
;
import { isLeft, isRight } from '@yoroi/common';
import { Api, Swap } from '@yoroi/types';
import { freeze } from 'immer';
import { dexhunterApiMaker } from './adapters/api/dexhunter/api-maker';
import { minswapApiMaker } from './adapters/api/minswap/api-maker';
import { muesliswapApiMaker } from './adapters/api/muesliswap/api-maker';
import { steelswapApiMaker } from './adapters/api/steelswap/api-maker';
import { getBestSwap } from './helpers/getBestSwap';
import { getPtPrice } from './helpers/getPtPrice';
export const swapManagerMaker = ({
address,
addressHex,
network,
primaryTokenInfo,
isPrimaryToken,
stakingKey,
storage,
partners
}) => {
const dexhunterApi = dexhunterApiMaker({
address,
network,
primaryTokenInfo,
isPrimaryToken,
partner: partners?.[Swap.Aggregator.Dexhunter]
});
const muesliswapApi = muesliswapApiMaker({
address,
addressHex,
network,
primaryTokenInfo,
stakingKey,
isPrimaryToken,
partner: partners?.[Swap.Aggregator.Muesliswap]
});
const minswapApi = minswapApiMaker({
address,
network,
primaryTokenInfo,
isPrimaryToken,
partner: partners?.[Swap.Aggregator.Minswap]
});
const steelswapApi = steelswapApiMaker({
address,
network,
primaryTokenInfo,
isPrimaryToken,
partner: partners?.[Swap.Aggregator.Steelswap]
});
const settings = {
routingPreference: 'auto',
slippage: 1
};
const assignSettings = v => {
const newSettings = Object.assign(settings, v);
storage.settings.save(newSettings);
return newSettings;
};
storage.settings.read().then(assignSettings);
// Only include adapters that have a partner code in the partners object
const adapters = {};
if (partners?.[Swap.Aggregator.Dexhunter]) {
adapters[Swap.Aggregator.Dexhunter] = dexhunterApi;
}
if (partners?.[Swap.Aggregator.Muesliswap]) {
adapters[Swap.Aggregator.Muesliswap] = muesliswapApi;
}
if (partners?.[Swap.Aggregator.Minswap]) {
adapters[Swap.Aggregator.Minswap] = minswapApi;
}
if (partners?.[Swap.Aggregator.Steelswap]) {
adapters[Swap.Aggregator.Steelswap] = steelswapApi;
}
const api = apiManagerMaker(adapters, settings, getPtPrice(primaryTokenInfo, dexhunterApi));
return {
api,
assignSettings,
settings,
clearStorage: storage.clear
};
};
const apiManagerMaker = (adapters, settings, getPrice) => {
// Helper function to get enabled aggregators based on routing preference
const getEnabledAggregators = () => {
if (settings.routingPreference === 'auto') {
return Object.keys(adapters);
}
// Filter to only include aggregators that exist in adapters
return settings.routingPreference.filter(agg => adapters[agg] !== undefined);
};
return freeze({
async tokens() {
const enabledAggregators = getEnabledAggregators();
const settledResults = await Promise.allSettled(enabledAggregators.map(aggregator => adapters[aggregator].tokens()));
const responses = [];
const errors = [];
settledResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
responses.push(result.value);
} else {
errors.push(`Aggregator ${enabledAggregators[index]} failed: ${result.reason}`);
}
});
if (errors.length > 0) {
console.warn('Some aggregators failed:', errors);
}
warnAllLeft(...responses);
if (responses.every(isLeft)) return invalid;
const merged = {};
const append = tokenInfo => {
if (merged[tokenInfo.id] === undefined) merged[tokenInfo.id] = tokenInfo;
};
responses.filter(isRight).flatMap(({
value
}) => value.data).forEach(append);
return {
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: Object.values(merged)
}
};
},
async orders() {
const enabledAggregators = Object.keys(adapters).filter(agg => adapters[agg] !== undefined);
const responses = await Promise.all(enabledAggregators.map(aggregator => adapters[aggregator].orders()));
warnAllLeft(...responses);
if (responses.every(isLeft)) return invalid;
const merged = {};
const append = order => {
const key = `${order.txHash}#${order.outputIndex}`;
if (merged[key] === undefined || order.aggregator === Swap.Aggregator.Dexhunter) merged[key] = order;
// Make sure we have Dexhunter's customId in case we need to cancel the order with them
if (order.customId && merged[key] && merged[key].customId === undefined) merged[key] = {
...merged[key],
customId: order.customId
};
};
responses.filter(isRight).flatMap(({
value
}) => value.data).forEach(append);
return {
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: Object.values(merged).sort(({
lastUpdate: A,
placedAt: A2
}, {
lastUpdate: B,
placedAt: B2
}) => (B ?? B2 ?? 0) - (A ?? A2 ?? 0))
}
};
},
async limitOptions(body) {
const enabledAggregators = getEnabledAggregators();
const responses = await Promise.all(enabledAggregators.map(aggregator => adapters[aggregator].limitOptions(body)));
warnAllLeft(...responses);
if (responses.every(isLeft)) return responses.find(res => res.error.status !== -3) ?? invalid;
const validResponses = responses.filter(isRight).map(({
value
}) => value.data);
if (validResponses.length === 0) return invalid;
const mergedOptions = {};
const append = res => {
mergedOptions[res.protocol] = res;
};
validResponses.forEach(({
options
}) => options.forEach(append));
const data = {
defaultProtocol: validResponses[0].defaultProtocol,
wantedPrice: Math.min(...validResponses.map(({
wantedPrice
}) => wantedPrice)),
options: Object.values(mergedOptions).filter(option => option.protocol !== Swap.Protocol.Unsupported)
};
return {
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data
}
};
},
async estimate(body) {
const enabledAggregators = getEnabledAggregators();
const settledResults = await Promise.allSettled(enabledAggregators.map(async aggregator => {
// If amountOut is provided and adapter supports reverse estimate, rely on adapter implementation.
const response = await adapters[aggregator].estimate(body);
return response;
}));
const responses = [];
const errors = [];
settledResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
responses.push(result.value);
} else {
errors.push(`Aggregator ${enabledAggregators[index]} failed: ${result.reason}`);
}
});
if (errors.length > 0) {
console.warn('Some aggregators failed during estimate:', errors);
}
warnAllLeft(...responses);
if (responses.every(isLeft)) return standarizeError(responses.find(res => res.error.status !== -3 && res.error.message !== '' && !res.error.message.includes('DOCTYPE html')) ?? invalid);
const estimates = responses.filter(isRight).flatMap(({
value
}) => value.data);
if (estimates.length === 0) {
return invalid;
}
const bestEstimate = estimates.reduce(getBestSwap(await getPrice(body.tokenOut)), estimates[0]);
return {
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: bestEstimate
}
};
},
async create(body) {
// Feature flag: single adapter create (default true)
const singleAdapterCreate = true;
if (singleAdapterCreate && body.routeHint?.aggregator != null) {
const adapter = adapters[body.routeHint.aggregator];
if (adapter == null) return invalid;
const response = await adapter.create(body);
if (isLeft(response)) return standarizeError(response);
return response;
}
// Fallback to legacy fan-out
const enabledAggregators = getEnabledAggregators();
const responses = await Promise.all(enabledAggregators.map(aggregator => adapters[aggregator].create(body)));
warnAllLeft(...responses);
if (responses.every(isLeft)) return standarizeError(responses.find(res => res.error.status !== -3 && res.error.message !== '' && !res.error.message.includes('DOCTYPE html')) ?? invalid);
const creates = responses.filter(isRight).map(({
value
}) => value.data);
const bestCreate = creates.reduce(getBestSwap(await getPrice(body.tokenOut)), creates[0]);
return {
tag: 'right',
value: {
status: Api.HttpStatusCode.Ok,
data: bestCreate
}
};
},
async cancel(body) {
// Helper function to check if response has valid CBOR
const hasValidCbor = response => {
return isRight(response) && response.value.data.cbor.trim() !== '';
};
// First, try the appropriate adapter based on aggregator
const initialAdapter = body.order.aggregator === Swap.Aggregator.Muesliswap ? adapters[Swap.Aggregator.Muesliswap] : body.order.aggregator === Swap.Aggregator.Minswap ? adapters[Swap.Aggregator.Minswap] : body.order.aggregator === Swap.Aggregator.Steelswap ? adapters[Swap.Aggregator.Steelswap] : adapters[Swap.Aggregator.Dexhunter];
if (!initialAdapter) return invalid;
const initialResponse = await initialAdapter.cancel(body);
// If we got a valid CBOR, return it
if (hasValidCbor(initialResponse)) {
return initialResponse;
}
// If not, try all other adapters in parallel
const otherAggregators = Object.entries(adapters).filter(([_, adapter]) => adapter !== initialAdapter);
const alternativeResponses = await Promise.all(otherAggregators.map(([_, adapter]) => adapter.cancel(body)));
// Find the first response with valid CBOR
const validResponse = alternativeResponses.find(hasValidCbor);
// If found, return it; otherwise return the initial response
return validResponse ?? initialResponse;
}
}, true);
};
const invalid = freeze({
tag: 'left',
error: {
status: -3,
message: 'Unknown error',
responseData: {}
}
}, true);
const warnAllLeft = (...responses) => {
if (responses.every(isLeft)) console.warn('Swap Manager all left >> ', responses.map(response => response.error.message));
};
export const standarizeError = input => {
if (isRight(input)) return input;
const response = {
...input,
error: {
...input.error
}
};
switch (true) {
case response.error.message.includes('Unable to build transaction due to insufficient user balance'):
case response.error.message.includes('Transaction Building Errornot enough funds'):
case response.error.message.includes('Transaction Building ErrorNo Remaining UTxOs'):
case response.error.message.includes('Insufficient balance'):
response.error.message = 'Insufficient balance: consider fees and assets blocked by staking.';
break;
case response.error.message.includes('amount_in_invalid'):
case response.error.message.includes('Buy and sell amounts must be positive'):
response.error.message = 'Buy and sell amounts must be positive';
break;
case response.error.message.includes('No liquidity available for this token pair'):
case response.error.message.includes('pool_not_found'):
response.error.message = 'This pair is not available in any liquidity pool.';
break;
case response.error.message.includes('DOCTYPE html'):
case response.error.message.includes('Could not find the number of decimals'):
response.error.message = 'Unknown error';
break;
}
return freeze(response, true);
};
//# sourceMappingURL=manager.js.map