@indigo-labs/indigo-sdk
Version:
Indigo SDK for interacting with Indigo endpoints via lucid-evolution
667 lines (607 loc) • 19.6 kB
text/typescript
import {
addAssets,
Assets,
fromHex,
toHex,
TxBuilder,
UTxO,
} from '@lucid-evolution/lucid';
import {
RobDatum,
RobOrderType,
parseRobDatumOrThrow,
serialiseRobDatum,
serialiseRobRedeemer,
} from './types-new';
import { calculateFeeFromRatio } from '../../utils/indigo-helpers';
import {
BigIntOrd,
fromDecimal,
sum,
zeroNegatives,
} from '../../utils/bigint-utils';
import {
readonlyArray as RA,
array as A,
function as F,
option as O,
ord as Ord,
} from 'fp-ts';
import { SystemParams } from '../../types/system-params';
import { match, P } from 'ts-pattern';
import { getInlineDatumOrThrow } from '../../utils/lucid-utils';
import {
adaAssetClass,
AssetClass,
assetClassValueOf,
isSameAssetClass,
lovelacesAmt,
mkAssetsOf,
} from '@3rd-eye-labs/cardano-offchain-common';
import {
Rational,
rationalFloor,
rationalFromInt,
rationalMul,
rationalToFloat,
} from '../../types/rational';
import { insertSorted, shuffle } from '../../utils/array-utils';
import { iassetValueOfCollateral } from '../cdp/helpers';
import { OracleIdx } from '../price-oracle/types-new';
import Decimal from 'decimal.js';
export const MIN_ROB_COLLATERAL_AMT = 3_000_000n;
/**
* The maximum of redemptions for a Tx that is doing only sell order redemptions.
* Based on the benchmarks.
*/
export const MAX_SELL_ROB_REDEMPTIONS_COUNT = 18;
/**
* The maximum of redemptions for a Tx that is doing only buy order redemptions.
* Based on the benchmarks.
*/
export const MAX_BUY_ROB_REDEMPTIONS_COUNT = 20;
/**
* Helper for ROB redemptions. A redeemer is selling iAssets against
* a buy order, i.e. buying a collateral asset.
* The return is the amount of collateral asset he buys.
*/
export function calculatePurchaseAmtWhenRobBuyOrder(
sellIassetAmt: bigint,
redemptionReimbursementRatio: Rational,
price: Rational,
): bigint {
const reimbursementIAsset = calculateFeeFromRatio(
redemptionReimbursementRatio,
sellIassetAmt,
);
const collateralForRedemption = rationalFloor(
rationalMul(rationalFromInt(sellIassetAmt - reimbursementIAsset), price),
);
return collateralForRedemption;
}
/**
* A redeemer is purchasing given collateral amount against buy order.
* Calculate what is the amount of iAsset the user sells/spends.
*/
export function calculateSpendAmtWhenRobBuyOrder(
purchaseCollateralAmt: bigint,
redemptionReimbursementRatio: Rational,
price: Rational,
): bigint {
const priceDecimal = Decimal(price.numerator).div(price.denominator);
const reimbRatio = Decimal(redemptionReimbursementRatio.numerator).div(
redemptionReimbursementRatio.denominator,
);
// TODO: make corrections here in case we can be ceiling this number instead.
// We want to be flooring here so that we don't go over the collateral to spend.
// Flooring multiple times is necessary here otherwise we could go over the collateral available.
// iassets = floor(collateral / price) / floor(1 - reimbursement fee)
return fromDecimal(
Decimal(purchaseCollateralAmt)
.div(priceDecimal)
.floor()
.div(Decimal(1).minus(reimbRatio))
.floor(),
);
}
/**
* Helper for ROB redemptions. A redeemer is selling collateral against
* a sell order, i.e. buying iassets.
* The return is the amount of iassets he buys.
*/
export function calculatePurchaseAmtWhenRobSellOrder(
sellCollateralAmt: bigint,
redemptionReimbursementRatio: Rational,
price: Rational,
): bigint {
const reimbursementCollateral = calculateFeeFromRatio(
redemptionReimbursementRatio,
sellCollateralAmt,
);
const redeemedIAssetAmt = iassetValueOfCollateral(
sellCollateralAmt - reimbursementCollateral,
price,
);
return redeemedIAssetAmt;
}
/**
* A redeemer is purchasing given iasset amount against sell order.
* Calculate what is the amount of collateral the user sells/spends.
*/
export function calculateSpendAmtWhenRobSellOrder(
purchaseIassetAmt: bigint,
redemptionReimbursementRatio: Rational,
price: Rational,
): bigint {
const priceDecimal = Decimal(price.numerator).div(price.denominator);
const reimbRatio = Decimal(redemptionReimbursementRatio.numerator).div(
redemptionReimbursementRatio.denominator,
);
// collateral = floor(iasset * price) / floor(1 - reimbursement fee)
return fromDecimal(
Decimal(purchaseIassetAmt)
.mul(priceDecimal)
.floor()
.div(Decimal(1).minus(reimbRatio))
.floor(),
);
}
/**
* The amount of collateral asset available in the ROB when buy order. In case of ADA, take
* into account the min UTXO collateral.
*/
export function robCollateralAmtToSpend(
robAssets: Assets,
robOrderType: RobOrderType,
): bigint {
return match(robOrderType)
.returnType<bigint>()
.with({ BuyIAssetOrder: P.select() }, (content) => {
if (isSameAssetClass(adaAssetClass, content.collateralAsset)) {
return zeroNegatives(lovelacesAmt(robAssets) - MIN_ROB_COLLATERAL_AMT);
} else {
return assetClassValueOf(robAssets, content.collateralAsset);
}
})
.otherwise(() => {
throw new Error('Collateral to spend is relevant only for Buy orders.');
});
}
/**
* The amount if iassets available in ROB when sell order.
*/
export function robIAssetAmtToSpend(
robAssets: Assets,
robOrderType: RobOrderType,
/**
* This has to be assetclass having policyID of Indigo iAssets,
* and the asset name being the ROB datum iasset.
*/
robIasset: AssetClass,
): bigint {
return match(robOrderType)
.returnType<bigint>()
.with({ SellIAssetOrder: P.any }, (_) => {
return assetClassValueOf(robAssets, robIasset);
})
.otherwise(() => {
throw new Error('IAssets to spend is relevant only for Sell orders.');
});
}
/**
* Amount to spend from the ROB universal for Buy and sell orders.
*/
export function robAmtToSpend(
robAssets: Assets,
robOrderType: RobOrderType,
/**
* This has to be assetclass having policyID of Indigo iAssets,
* and the asset name being the ROB datum iasset.
*/
robIasset: AssetClass,
): bigint {
return match(robOrderType)
.with({ BuyIAssetOrder: P.any }, () =>
robCollateralAmtToSpend(robAssets, robOrderType),
)
.with({ SellIAssetOrder: P.any }, () =>
robIAssetAmtToSpend(robAssets, robOrderType, robIasset),
)
.exhaustive();
}
type FilledResult = { asset: AssetClass; filledAmt: bigint };
/**
* The assets that have filled the order and are able to be claimed.
*/
export function robBuyOrderFilledAssets(
robAssets: Assets,
robOrderType: RobOrderType,
/**
* This has to be assetclass having policyID of Indigo iAssets,
* and the asset name being the ROB datum iasset.
*/
robIasset: AssetClass,
): FilledResult {
return match(robOrderType)
.returnType<FilledResult>()
.with({ BuyIAssetOrder: P.any }, () => {
return {
asset: robIasset,
filledAmt: assetClassValueOf(robAssets, robIasset),
};
})
.otherwise(() => {
throw new Error('Expected only buy order.');
});
}
/**
* The assets that have filled the order and are able to be claimed.
*/
export function robSellOrderFilledAssets(
robAssets: Assets,
robOrderType: RobOrderType,
): FilledResult[] {
return match(robOrderType)
.returnType<FilledResult[]>()
.with({ SellIAssetOrder: P.select() }, (content) => {
return content.allowedCollateralAssets.map(([asset, _]) => {
return {
asset: asset,
filledAmt:
assetClassValueOf(robAssets, asset) -
(isSameAssetClass(asset, adaAssetClass)
? MIN_ROB_COLLATERAL_AMT
: 0n),
} satisfies FilledResult;
});
})
.otherwise(() => {
throw new Error('Expected only sell order.');
});
}
export function robBuyOrderSummary(
robAssets: Assets,
robOrderType: RobOrderType,
oraclePrice: Rational,
): {
/**
* The amount that can be spent from the ROB.
*/
redeemableCollateral: bigint;
/**
* The amount paid to the ROB when everything redeemed.
*/
payoutIAsset: bigint;
} {
const redeemable = robCollateralAmtToSpend(robAssets, robOrderType);
// TODO: this is incorrect since it doesn't take into account the reimbursement ratio.
const payoutAmt = iassetValueOfCollateral(redeemable, oraclePrice);
return {
redeemableCollateral: redeemable,
payoutIAsset: payoutAmt,
};
}
/**
* In case it's applied to a sell order instead, it will throw an error.
*/
export function isBuyOrderFullyRedeemed(
robAssets: Assets,
robOrderType: RobOrderType,
oraclePrice: Rational,
): boolean {
const summary = robBuyOrderSummary(robAssets, robOrderType, oraclePrice);
return summary.redeemableCollateral <= 0n || summary.payoutIAsset <= 0n;
}
/**
* Use the limit prices to decide fully redeemed.
*/
export function isFullyRedeemed(
robAssets: Assets,
robOrderType: RobOrderType,
/**
* This has to be assetclass having policyID of Indigo iAssets,
* and the asset name being the ROB datum iasset.
*/
robIasset: AssetClass,
): boolean {
return match(robOrderType)
.returnType<boolean>()
.with({ BuyIAssetOrder: P.select() }, (content) =>
isBuyOrderFullyRedeemed(robAssets, robOrderType, content.maxPrice),
)
.with({ SellIAssetOrder: P.select() }, (content) => {
const iassetToSpend = robIAssetAmtToSpend(
robAssets,
robOrderType,
robIasset,
);
const payoutAmts = content.allowedCollateralAssets.map((c) =>
rationalFloor(rationalMul(rationalFromInt(iassetToSpend), c[1])),
);
return (
iassetToSpend <= 0n ||
// When for every allowed collateral asset the payout would be 0
payoutAmts.every((amt) => amt <= 0n)
);
})
.exhaustive();
}
/**
* Right now we allow multi redemptions when the collateral asset, iasset pair is the same.
* The on-chain however should allow even other combinations.
*/
export function buildRedemptionsTx(
/**
* The tuple represents the ROB UTXO and the amount to payout for a redemption. In case of buy order,
* it's denominated in iAssets, in case of sell order, it's denominated in collateral asset.
*/
redemptions: [UTxO, bigint][],
iasset: Uint8Array<ArrayBufferLike>,
collateralAsset: AssetClass,
price: Rational,
redemptionReimbursementRatio: Rational,
sysParams: SystemParams,
tx: TxBuilder,
/**
* The number of Tx outputs before these new ones.
*/
txOutputsBeforeCount: bigint,
collateralAssetRefInputIdx: bigint,
iassetRefInputIdx: bigint,
oracleIdx: OracleIdx,
): TxBuilder {
return F.pipe(
redemptions,
A.reduceWithIndex<[UTxO, bigint], TxBuilder>(
tx,
(idx, acc, [robUtxo, payoutAmt]) => {
const robDatum = parseRobDatumOrThrow(getInlineDatumOrThrow(robUtxo));
if (toHex(robDatum.iasset) !== toHex(iasset)) {
throw new Error('Only same iAsset');
}
const [robOutputVal, sellOrderAllowedAssetsIdx] = match(
robDatum.orderType,
)
.returnType<[Assets, bigint]>()
.with({ BuyIAssetOrder: P.select() }, (content) => {
if (!isSameAssetClass(content.collateralAsset, collateralAsset)) {
throw new Error('Only same collateral asset');
}
const payoutIAssetAmt = payoutAmt;
const collateralForRedemption = calculatePurchaseAmtWhenRobBuyOrder(
payoutIAssetAmt,
redemptionReimbursementRatio,
price,
);
const resultVal = addAssets(
robUtxo.assets,
mkAssetsOf(collateralAsset, -collateralForRedemption),
mkAssetsOf(
{
currencySymbol: fromHex(
sysParams.robParams.iassetPolicyId.unCurrencySymbol,
),
tokenName: robDatum.iasset,
},
payoutIAssetAmt,
),
);
return [resultVal, 0n];
})
.with({ SellIAssetOrder: P.select() }, (content) => {
const allowedAssetIdx = F.pipe(
content.allowedCollateralAssets,
RA.findIndex(([asset, _]) =>
isSameAssetClass(asset, collateralAsset),
),
O.getOrElse<number>(() => {
throw new Error("Doesn't allow required collateral asset.");
}),
);
const payoutCollateralAmt = payoutAmt;
const redeemedIAssetAmt = calculatePurchaseAmtWhenRobSellOrder(
payoutCollateralAmt,
redemptionReimbursementRatio,
price,
);
const resultVal = addAssets(
robUtxo.assets,
mkAssetsOf(collateralAsset, payoutCollateralAmt),
mkAssetsOf(
{
currencySymbol: fromHex(
sysParams.robParams.iassetPolicyId.unCurrencySymbol,
),
tokenName: robDatum.iasset,
},
-redeemedIAssetAmt,
),
);
return [resultVal, BigInt(allowedAssetIdx)];
})
.exhaustive();
if (lovelacesAmt(robOutputVal) < MIN_ROB_COLLATERAL_AMT) {
throw new Error(
'Redeeming more than available or selected ROB was incorrectly initialised.',
);
}
return acc
.collectFrom([robUtxo], {
kind: 'self',
makeRedeemer: (ownIdx) =>
serialiseRobRedeemer({
Redeem: {
ownInputIdx: ownIdx,
collateralAssetRefInputIdx: collateralAssetRefInputIdx,
iassetRefInputIdx: iassetRefInputIdx,
continuingOutputIdx: txOutputsBeforeCount + BigInt(idx),
sellOrderAllowedAssetsIdx: sellOrderAllowedAssetsIdx,
priceOracleIdx: oracleIdx,
},
}),
})
.pay.ToContract(
robUtxo.address,
{
kind: 'inline',
value: serialiseRobDatum({
...robDatum,
robRefInput: {
outputIndex: BigInt(robUtxo.outputIndex),
txHash: fromHex(robUtxo.txHash),
},
}),
},
robOutputVal,
);
},
),
);
}
/**
* Given all available LRP UTXOs, calculate total available collateral that can be redeemed.
* Taking into account incorrectly initialised LRPs (without base collateral) and max number of ROBs.
*/
export function calculateTotalCollateralForRedemption(
iasset: Uint8Array<ArrayBufferLike>,
collateralAsset: AssetClass,
iassetPrice: Rational,
allRobs: [UTxO, RobDatum][],
/**
* How many LRPs can be redeemed in a single Tx.
*/
maxRobsInTx: number,
): bigint {
return F.pipe(
allRobs,
A.filterMap(([utxo, datum]) => {
const isCorrectOrder = match(datum.orderType)
.returnType<boolean>()
.with(
{ BuyIAssetOrder: P.select() },
(content) =>
isSameAssetClass(content.collateralAsset, collateralAsset) &&
rationalToFloat(content.maxPrice) >= rationalToFloat(iassetPrice) &&
!isBuyOrderFullyRedeemed(utxo.assets, datum.orderType, iassetPrice),
)
.otherwise(() => false);
if (toHex(datum.iasset) !== toHex(iasset) || !isCorrectOrder) {
return O.none;
}
// We constrained in the logic above that the ROB is a buy order and is not yet fully redeemed.
const collateralToSpend = robCollateralAmtToSpend(
utxo.assets,
datum.orderType,
);
return O.some(collateralToSpend);
}),
// From largest to smallest
A.sort(Ord.reverse(BigIntOrd)),
// We can fit only this number of redemptions with CDP open into a single Tx.
A.takeLeft(maxRobsInTx),
sum,
);
}
/**
* Pick random subset from all the ROBs (it does the necessary filtering) satisfying the target collateral to spend.
* It's relevant for BUY orders only.
*/
export function randomRobsSubsetSatisfyingTargetCollateral(
iasset: Uint8Array<ArrayBufferLike>,
collateralAsset: AssetClass,
targetCollateralToSpend: bigint,
iassetPrice: Rational,
allLrps: [UTxO, RobDatum][],
/**
* How many LRPs can be redeemed in a single Tx.
*/
maxLrpsInTx: number,
randomiseFn: (arr: [UTxO, RobDatum][]) => [UTxO, RobDatum][] = shuffle,
): [UTxO, RobDatum][] {
if (
targetCollateralToSpend <= 0n ||
iassetValueOfCollateral(targetCollateralToSpend, iassetPrice) <= 0n
) {
throw new Error('Must redeem and payout more than 0.');
}
const shuffled = randomiseFn(
F.pipe(
allLrps,
A.filter(
([utxo, datum]) =>
toHex(datum.iasset) === toHex(iasset) &&
match(datum.orderType)
.with(
{ BuyIAssetOrder: P.select() },
(content) =>
isSameAssetClass(collateralAsset, content.collateralAsset) &&
rationalToFloat(content.maxPrice) >=
rationalToFloat(iassetPrice),
)
// Only buy order types
.otherwise(() => false) &&
!isBuyOrderFullyRedeemed(utxo.assets, datum.orderType, iassetPrice),
),
),
);
// Sorted from highest to lowest by lovelaces to spend
let result: [UTxO, RobDatum][] = [];
let runningSum = 0n;
for (let i = 0; i < shuffled.length; i++) {
const element = shuffled[i];
const lovelacesToSpend = robCollateralAmtToSpend(
element[0].assets,
element[1].orderType,
);
const remainingToRedeem = targetCollateralToSpend - runningSum;
const remainingToPayout = iassetValueOfCollateral(
remainingToRedeem,
iassetPrice,
);
// When we can't add a new redemption because otherwise there would be no payout.
// Try to replace the smallest collected with a following larger one when available.
if (result.length > 0 && remainingToPayout <= 0n) {
const last = result[result.length - 1];
const lastSummary = robBuyOrderSummary(
last[0].assets,
last[1].orderType,
iassetPrice,
);
// Pop the smallest collected when the current is larger.
if (lastSummary.redeemableCollateral < lovelacesToSpend) {
result.pop()!;
runningSum -= lastSummary.redeemableCollateral;
} else {
continue;
}
}
result = insertSorted(
result,
element,
Ord.contramap<bigint, [UTxO, RobDatum]>(
([utxo, dat]) => robCollateralAmtToSpend(utxo.assets, dat.orderType),
// From highest to lowest
)(Ord.reverse(BigIntOrd)),
);
runningSum += lovelacesToSpend;
// When more items than max allowed, pop the one with smallest value
if (result.length > maxLrpsInTx) {
const popped = result.pop()!;
runningSum -= robCollateralAmtToSpend(
popped[0].assets,
popped[1].orderType,
);
}
if (runningSum >= targetCollateralToSpend) {
return result;
}
}
const remainingToSpend = targetCollateralToSpend - runningSum;
if (
remainingToSpend > 0n &&
iassetValueOfCollateral(remainingToSpend, iassetPrice) > 0n
) {
throw new Error("Couldn't achieve target lovelaces");
}
return result;
}