UNPKG

@indigo-labs/indigo-sdk

Version:

Indigo SDK for interacting with Indigo endpoints via lucid-evolution

667 lines (607 loc) 19.6 kB
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; }