mirror of
https://github.com/orionprotocol/sdk.git
synced 2026-03-14 06:02:36 +03:00
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
import { BigNumber } from 'bignumber.js';
|
|
import { ethers } from 'ethers';
|
|
import { Exchange__factory } from '@orionprotocol/contracts/lib/ethers-v6/index.js';
|
|
import getBalances from '../../utils/getBalances.js';
|
|
import BalanceGuard from '../../BalanceGuard.js';
|
|
import getAvailableSources from '../../utils/getAvailableFundsSources.js';
|
|
import type Unit from '../index.js';
|
|
import { INTERNAL_PROTOCOL_PRECISION, NATIVE_CURRENCY_PRECISION, SWAP_THROUGH_ORION_POOL_GAS_LIMIT } from '../../constants/index.js';
|
|
import getNativeCryptocurrencyName from '../../utils/getNativeCryptocurrencyName.js';
|
|
import { calculateFeeInFeeAsset, denormalizeNumber, normalizeNumber } from '../../utils/index.js';
|
|
import { signOrder } from '../../crypt/index.js';
|
|
import type orderSchema from '../../services/Aggregator/schemas/orderSchema.js';
|
|
import type { z } from 'zod';
|
|
import { simpleFetch } from 'simple-typed-fetch';
|
|
|
|
export type SwapLimitParams = {
|
|
type: 'exactSpend' | 'exactReceive'
|
|
assetIn: string
|
|
assetOut: string
|
|
price: BigNumber.Value
|
|
amount: BigNumber.Value
|
|
feeAsset: string
|
|
signer: ethers.Signer
|
|
unit: Unit
|
|
options?: {
|
|
poolOnly?: boolean
|
|
instantSettlement?: boolean
|
|
logger?: (message: string) => void
|
|
autoApprove?: boolean
|
|
developer?: {
|
|
route?: 'aggregator' | 'pool'
|
|
}
|
|
}
|
|
}
|
|
|
|
type AggregatorOrder = {
|
|
amountOut: number
|
|
through: 'aggregator'
|
|
id: string
|
|
wait: () => Promise<z.infer<typeof orderSchema>>
|
|
}
|
|
|
|
type PoolSwap = {
|
|
amountOut: number
|
|
through: 'pool'
|
|
txHash: string
|
|
wait: (confirmations?: number | undefined) => Promise<ethers.TransactionReceipt | null>
|
|
}
|
|
|
|
export type Swap = AggregatorOrder | PoolSwap;
|
|
|
|
export default async function swapLimit({
|
|
type,
|
|
assetIn,
|
|
assetOut,
|
|
price,
|
|
amount,
|
|
feeAsset,
|
|
signer,
|
|
unit,
|
|
options,
|
|
}: SwapLimitParams): Promise<Swap> {
|
|
if (options?.developer) options.logger?.('YOU SPECIFIED A DEVELOPER OPTIONS. BE CAREFUL!');
|
|
if (amount === '') throw new Error('Amount can not be empty');
|
|
if (assetIn === '') throw new Error('AssetIn can not be empty');
|
|
if (assetOut === '') throw new Error('AssetOut can not be empty');
|
|
if (feeAsset === '') throw new Error('Fee asset can not be empty');
|
|
if (price === '') throw new Error('Price can not be empty');
|
|
|
|
const amountBN = new BigNumber(amount);
|
|
if (amountBN.isNaN()) throw new Error(`Amount '${amountBN.toString()}' is not a number`);
|
|
if (amountBN.lte(0)) throw new Error(`Amount '${amountBN.toString()}' should be greater than 0`);
|
|
|
|
const priceBN = new BigNumber(price);
|
|
if (priceBN.isNaN()) throw new Error(`Price '${priceBN.toString()}' is not a number`);
|
|
if (priceBN.lte(0)) throw new Error('Price should be greater than 0');
|
|
|
|
const walletAddress = await signer.getAddress();
|
|
options?.logger?.(`Wallet address is ${walletAddress}`);
|
|
|
|
const {
|
|
blockchainService, aggregator, provider, chainId,
|
|
} = unit;
|
|
const {
|
|
exchangeContractAddress,
|
|
matcherAddress,
|
|
assetToAddress,
|
|
} = await simpleFetch(blockchainService.getInfo)();
|
|
const nativeCryptocurrency = getNativeCryptocurrencyName(assetToAddress);
|
|
|
|
const exchangeContract = Exchange__factory.connect(exchangeContractAddress, provider);
|
|
const feeAssets = await simpleFetch(blockchainService.getPlatformFees)({ walletAddress, assetIn, assetOut });
|
|
const allPrices = await simpleFetch(blockchainService.getPricesWithQuoteAsset)();
|
|
const gasPriceWei = await simpleFetch(blockchainService.getGasPriceWei)();
|
|
const { factories } = await simpleFetch(blockchainService.getPoolsConfig)();
|
|
const poolExchangesList = factories !== undefined ? Object.keys(factories) : [];
|
|
|
|
const gasPriceGwei = ethers.formatUnits(gasPriceWei, 'gwei').toString();
|
|
|
|
const assetInAddress = assetToAddress[assetIn];
|
|
if (assetInAddress === undefined) throw new Error(`Asset '${assetIn}' not found`);
|
|
const feeAssetAddress = assetToAddress[feeAsset];
|
|
if (feeAssetAddress === undefined) {
|
|
throw new Error(`Fee asset '${feeAsset}' not found. Available assets: ${Object.keys(feeAssets).join(', ')}`);
|
|
}
|
|
|
|
const balances = await getBalances(
|
|
{
|
|
[assetIn]: assetInAddress,
|
|
[feeAsset]: feeAssetAddress,
|
|
[nativeCryptocurrency]: ethers.ZeroAddress,
|
|
},
|
|
aggregator,
|
|
walletAddress,
|
|
exchangeContract,
|
|
provider,
|
|
);
|
|
|
|
const balanceGuard = new BalanceGuard(
|
|
balances,
|
|
{
|
|
name: nativeCryptocurrency,
|
|
address: ethers.ZeroAddress,
|
|
},
|
|
provider,
|
|
signer,
|
|
options?.logger,
|
|
);
|
|
|
|
const swapInfo = await simpleFetch(aggregator.getSwapInfo)(
|
|
type,
|
|
assetIn,
|
|
assetOut,
|
|
amountBN.toString(),
|
|
options?.instantSettlement,
|
|
options?.poolOnly !== undefined && options.poolOnly
|
|
? 'pools'
|
|
: undefined,
|
|
);
|
|
|
|
const { exchanges: swapExchanges } = swapInfo;
|
|
|
|
const [firstSwapExchange] = swapExchanges;
|
|
|
|
if (swapExchanges.length > 0) options?.logger?.(`Swap exchanges: ${swapExchanges.join(', ')}`);
|
|
|
|
if (swapInfo.type === 'exactReceive' && amountBN.lt(swapInfo.minAmountOut)) {
|
|
throw new Error(`Amount is too low. Min amountOut is ${swapInfo.minAmountOut} ${assetOut}`);
|
|
}
|
|
|
|
if (swapInfo.type === 'exactSpend' && amountBN.lt(swapInfo.minAmountIn)) {
|
|
throw new Error(`Amount is too low. Min amountIn is ${swapInfo.minAmountIn} ${assetIn}`);
|
|
}
|
|
|
|
if (swapInfo.orderInfo === null) throw new Error(swapInfo.executionInfo);
|
|
|
|
const [baseAssetName, quoteAssetName] = swapInfo.orderInfo.assetPair.split('-');
|
|
if (baseAssetName === undefined) throw new Error('Base asset name is undefined');
|
|
if (quoteAssetName === undefined) throw new Error('Quote asset name is undefined');
|
|
|
|
const pairConfig = await simpleFetch(aggregator.getPairConfig)(`${baseAssetName}-${quoteAssetName}`);
|
|
const minQtyBN = new BigNumber(pairConfig.minQty);
|
|
const qtyPrecisionBN = new BigNumber(pairConfig.qtyPrecision);
|
|
const pricePrecisionBN = new BigNumber(pairConfig.pricePrecision);
|
|
const minPrice = new BigNumber(pairConfig.minPrice);
|
|
const maxPrice = new BigNumber(pairConfig.maxPrice);
|
|
|
|
const qtyDecimalPlaces = amountBN.dp();
|
|
const priceDecimalPlaces = priceBN.dp();
|
|
|
|
if (qtyDecimalPlaces === null) throw new Error('Qty decimal places is null. Likely amount is -Infinity, +Infinity or NaN');
|
|
if (qtyPrecisionBN.lt(qtyDecimalPlaces)) {
|
|
throw new Error(
|
|
`Actual amount decimal places (${qtyDecimalPlaces}) is greater than max allowed decimal places (${qtyPrecisionBN.toString()}) on pair ${baseAssetName}-${quoteAssetName}.`
|
|
);
|
|
}
|
|
if (priceDecimalPlaces === null) throw new Error('Price decimal places is null. Likely price is -Infinity, +Infinity or NaN');
|
|
if (pricePrecisionBN.lt(priceDecimalPlaces)) {
|
|
throw new Error(
|
|
`Actual price decimal places (${priceDecimalPlaces}) is greater than max allowed decimal places (${pricePrecisionBN.toString()}) on pair ${baseAssetName}-${quoteAssetName}.`
|
|
);
|
|
}
|
|
|
|
if (priceBN.lt(minPrice)) {
|
|
throw new Error(`Price is too low. Min price is ${minPrice.toString()} ${quoteAssetName}`);
|
|
}
|
|
if (priceBN.gt(maxPrice)) {
|
|
throw new Error(`Price is too high. Max price is ${maxPrice.toString()} ${quoteAssetName}`);
|
|
}
|
|
|
|
options?.logger?.(`Safe price is ${swapInfo.orderInfo.safePrice} ${quoteAssetName}`);
|
|
// BTEMP — better than or equal market price
|
|
const priceIsBTEMP = type === 'exactSpend'
|
|
? priceBN.lte(swapInfo.orderInfo.safePrice)
|
|
: priceBN.gte(swapInfo.orderInfo.safePrice);
|
|
|
|
options?.logger?.(`Your price ${priceBN.toString()} is ${priceIsBTEMP ? 'better than or equal' : 'worse than'} market price ${swapInfo.orderInfo.safePrice}`);
|
|
|
|
let route: 'aggregator' | 'pool';
|
|
|
|
if (options?.developer?.route !== undefined) {
|
|
if (options.developer.route === 'pool' && !priceIsBTEMP) {
|
|
throw new Error(
|
|
'CONFLICT: Pool execution is not available for this swap.' +
|
|
' Price is worse than market price. Please unset "route" option or set it to "aggregator"'
|
|
);
|
|
}
|
|
route = options.developer.route;
|
|
options.logger?.(`Swap is through ${route} (because route forced to ${route})`);
|
|
} else if (options?.poolOnly !== undefined && options.poolOnly) {
|
|
if (!priceIsBTEMP) {
|
|
throw new Error(
|
|
'CONFLICT: Pool execution is not available for this swap.' +
|
|
' Price is worse than market price. Please disable "poolOnly" option'
|
|
);
|
|
}
|
|
options.logger?.('Swap is through pool (because "poolOnly" option is true)');
|
|
route = 'pool';
|
|
} else if (
|
|
poolExchangesList.length > 0 &&
|
|
swapExchanges.length === 1 &&
|
|
firstSwapExchange !== undefined &&
|
|
poolExchangesList.some((poolExchange) => poolExchange === firstSwapExchange) &&
|
|
priceIsBTEMP
|
|
) {
|
|
options?.logger?.(`Swap is through pool [via ${firstSwapExchange}] (detected by "exchanges" field)`);
|
|
route = 'pool';
|
|
} else {
|
|
route = 'aggregator';
|
|
}
|
|
|
|
if (route === 'pool') {
|
|
let factoryAddress: string | undefined;
|
|
if (factories && firstSwapExchange !== undefined) {
|
|
factoryAddress = factories[firstSwapExchange];
|
|
if (factoryAddress !== undefined) options?.logger?.(`Factory address is ${factoryAddress}. Exchange is ${firstSwapExchange}`);
|
|
}
|
|
|
|
const pathAddresses = swapInfo.path.map((name) => {
|
|
const assetAddress = assetToAddress[name];
|
|
if (assetAddress === undefined) throw new Error(`No asset address for ${name}`);
|
|
return assetAddress;
|
|
});
|
|
|
|
const amountSpend = swapInfo.type === 'exactSpend'
|
|
? swapInfo.amountIn
|
|
: new BigNumber(swapInfo.orderInfo.amount).multipliedBy(swapInfo.orderInfo.safePrice)
|
|
|
|
balanceGuard.registerRequirement({
|
|
reason: 'Amount spend',
|
|
asset: {
|
|
name: assetIn,
|
|
address: assetInAddress,
|
|
},
|
|
amount: amountSpend.toString(),
|
|
spenderAddress: exchangeContractAddress,
|
|
sources: getAvailableSources('amount', assetInAddress, 'pool'),
|
|
});
|
|
|
|
const amountReceive = swapInfo.type === 'exactReceive'
|
|
? swapInfo.amountOut
|
|
: new BigNumber(swapInfo.orderInfo.amount).multipliedBy(swapInfo.orderInfo.safePrice)
|
|
const amountSpendBlockchainParam = normalizeNumber(
|
|
amountSpend,
|
|
INTERNAL_PROTOCOL_PRECISION,
|
|
BigNumber.ROUND_CEIL,
|
|
);
|
|
const amountReceiveBlockchainParam = normalizeNumber(
|
|
amountReceive,
|
|
INTERNAL_PROTOCOL_PRECISION,
|
|
BigNumber.ROUND_FLOOR,
|
|
);
|
|
|
|
const unsignedSwapThroughOrionPoolTx = await exchangeContract.swapThroughOrionPool.populateTransaction(
|
|
amountSpendBlockchainParam,
|
|
amountReceiveBlockchainParam,
|
|
factoryAddress !== undefined
|
|
? [factoryAddress, ...pathAddresses]
|
|
: pathAddresses,
|
|
type === 'exactSpend',
|
|
);
|
|
|
|
unsignedSwapThroughOrionPoolTx.chainId = BigInt(parseInt(chainId, 10));
|
|
unsignedSwapThroughOrionPoolTx.gasPrice = BigInt(gasPriceWei);
|
|
|
|
unsignedSwapThroughOrionPoolTx.from = walletAddress;
|
|
const amountSpendBN = new BigNumber(amountSpend);
|
|
|
|
let value = new BigNumber(0);
|
|
const denormalizedAssetInExchangeBalance = balances[assetIn]?.exchange;
|
|
if (denormalizedAssetInExchangeBalance === undefined) throw new Error(`Asset '${assetIn}' exchange balance is not found`);
|
|
if (assetIn === nativeCryptocurrency && amountSpendBN.gt(denormalizedAssetInExchangeBalance)) {
|
|
value = amountSpendBN.minus(denormalizedAssetInExchangeBalance);
|
|
}
|
|
unsignedSwapThroughOrionPoolTx.value = normalizeNumber(
|
|
value.dp(INTERNAL_PROTOCOL_PRECISION, BigNumber.ROUND_CEIL),
|
|
NATIVE_CURRENCY_PRECISION,
|
|
BigNumber.ROUND_CEIL,
|
|
);
|
|
unsignedSwapThroughOrionPoolTx.gasLimit = BigInt(SWAP_THROUGH_ORION_POOL_GAS_LIMIT);
|
|
|
|
const transactionCost = BigInt(SWAP_THROUGH_ORION_POOL_GAS_LIMIT) * BigInt(gasPriceWei);
|
|
const denormalizedTransactionCost = denormalizeNumber(transactionCost, BigInt(NATIVE_CURRENCY_PRECISION));
|
|
|
|
balanceGuard.registerRequirement({
|
|
reason: 'Network fee',
|
|
asset: {
|
|
name: nativeCryptocurrency,
|
|
address: ethers.ZeroAddress,
|
|
},
|
|
amount: denormalizedTransactionCost.toString(),
|
|
sources: getAvailableSources('network_fee', ethers.ZeroAddress, 'pool'),
|
|
});
|
|
|
|
// if (value.gt(0)) {
|
|
// balanceGuard.registerRequirement({
|
|
// reason: 'Transaction value (extra amount)',
|
|
// asset: {
|
|
// name: nativeCryptocurrency,
|
|
// address: ethers.ZeroAddress,
|
|
// },
|
|
// amount: value.toString(),
|
|
// sources: getAvailableSources('amount', ethers.ZeroAddress, 'pool'),
|
|
// });
|
|
// }
|
|
|
|
await balanceGuard.check(options?.autoApprove);
|
|
|
|
const nonce = await provider.getTransactionCount(walletAddress, 'pending');
|
|
unsignedSwapThroughOrionPoolTx.nonce = nonce;
|
|
|
|
options?.logger?.('Signing transaction...');
|
|
const swapThroughOrionPoolTxResponse = await signer.sendTransaction(unsignedSwapThroughOrionPoolTx);
|
|
options?.logger?.(`Transaction sent. Tx hash: ${swapThroughOrionPoolTxResponse.hash}`);
|
|
return {
|
|
amountOut: swapInfo.amountOut,
|
|
wait: swapThroughOrionPoolTxResponse.wait.bind(swapThroughOrionPoolTxResponse),
|
|
through: 'pool',
|
|
txHash: swapThroughOrionPoolTxResponse.hash,
|
|
};
|
|
}
|
|
options?.logger?.('Swap through aggregator');
|
|
|
|
if (amountBN.lt(minQtyBN)) {
|
|
throw new Error(`Amount is too low. Min amount is ${minQtyBN.toString()} ${baseAssetName}`);
|
|
}
|
|
|
|
const baseAssetAddress = assetToAddress[baseAssetName];
|
|
if (baseAssetAddress === undefined) throw new Error(`No asset address for ${baseAssetName}`);
|
|
const quoteAssetAddress = assetToAddress[quoteAssetName];
|
|
if (quoteAssetAddress === undefined) throw new Error(`No asset address for ${quoteAssetName}`);
|
|
|
|
const safePriceWithAppliedPrecision = priceBN
|
|
.decimalPlaces(
|
|
pairConfig.pricePrecision,
|
|
swapInfo.orderInfo.side === 'BUY'
|
|
? BigNumber.ROUND_CEIL
|
|
: BigNumber.ROUND_FLOOR,
|
|
);
|
|
|
|
balanceGuard.registerRequirement({
|
|
reason: 'Amount',
|
|
asset: {
|
|
name: assetIn,
|
|
address: assetInAddress,
|
|
},
|
|
amount: swapInfo.orderInfo.side === 'SELL'
|
|
? swapInfo.orderInfo.amount.toString()
|
|
: safePriceWithAppliedPrecision.multipliedBy(swapInfo.orderInfo.amount).toString(),
|
|
spenderAddress: exchangeContractAddress,
|
|
sources: getAvailableSources('amount', assetInAddress, 'aggregator'),
|
|
});
|
|
|
|
// Fee calculation
|
|
const feePercent = feeAssets[feeAsset];
|
|
if (feePercent === undefined) throw new Error(`Fee asset ${feeAsset} not available`);
|
|
|
|
const { serviceFeeInFeeAsset, networkFeeInFeeAsset, totalFeeInFeeAsset } = calculateFeeInFeeAsset(
|
|
swapInfo.orderInfo.amount,
|
|
gasPriceGwei,
|
|
feePercent,
|
|
baseAssetAddress,
|
|
ethers.ZeroAddress,
|
|
feeAssetAddress,
|
|
allPrices.prices,
|
|
);
|
|
|
|
if (feeAsset === assetOut) {
|
|
options?.logger?.('Fee asset equals received asset. The fee can be paid from the amount received');
|
|
options?.logger?.(`Set extra balance: + ${swapInfo.amountOut} ${assetOut} to exchange`);
|
|
|
|
balanceGuard.setExtraBalance(feeAsset, swapInfo.amountOut, 'exchange');
|
|
}
|
|
|
|
balanceGuard.registerRequirement({
|
|
reason: 'Network fee',
|
|
asset: {
|
|
name: feeAsset,
|
|
address: feeAssetAddress,
|
|
},
|
|
amount: networkFeeInFeeAsset.toString(),
|
|
spenderAddress: exchangeContractAddress,
|
|
sources: getAvailableSources('network_fee', feeAssetAddress, 'aggregator'),
|
|
});
|
|
|
|
balanceGuard.registerRequirement({
|
|
reason: 'Service fee',
|
|
asset: {
|
|
name: feeAsset,
|
|
address: feeAssetAddress,
|
|
},
|
|
amount: serviceFeeInFeeAsset.toString(),
|
|
spenderAddress: exchangeContractAddress,
|
|
sources: getAvailableSources('service_fee', feeAssetAddress, 'aggregator'),
|
|
});
|
|
|
|
await balanceGuard.check(options?.autoApprove);
|
|
|
|
const signedOrder = await signOrder(
|
|
baseAssetAddress,
|
|
quoteAssetAddress,
|
|
swapInfo.orderInfo.side,
|
|
safePriceWithAppliedPrecision.toString(),
|
|
swapInfo.orderInfo.amount,
|
|
totalFeeInFeeAsset,
|
|
walletAddress,
|
|
matcherAddress,
|
|
feeAssetAddress,
|
|
signer,
|
|
chainId,
|
|
);
|
|
const orderIsOk = await exchangeContract.validateOrder(signedOrder);
|
|
if (!orderIsOk) throw new Error('Order is not valid');
|
|
|
|
const { orderId } = await simpleFetch(aggregator.placeOrder)(signedOrder, false);
|
|
options?.logger?.(`Order placed. Order id: ${orderId}`);
|
|
|
|
return {
|
|
amountOut: amountBN.multipliedBy(safePriceWithAppliedPrecision).toNumber(),
|
|
wait: () => new Promise<z.infer<typeof orderSchema>>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error('Timeout'))
|
|
}, 60000);
|
|
const interval = setInterval(() => {
|
|
simpleFetch(aggregator.getOrder)(orderId).then((data) => {
|
|
if (data.order.status === 'SETTLED') {
|
|
options?.logger?.(`Order ${orderId} settled`);
|
|
clearTimeout(timeout);
|
|
clearInterval(interval);
|
|
resolve(data);
|
|
} else {
|
|
options?.logger?.(`Order ${orderId} status: ${data.order.status}`);
|
|
}
|
|
}).catch((e) => {
|
|
if (!(e instanceof Error)) throw new Error('Not an error');
|
|
options?.logger?.(`Error while getting order status: ${e.message}`);
|
|
});
|
|
}, 1000);
|
|
}),
|
|
through: 'aggregator',
|
|
id: orderId,
|
|
};
|
|
}
|