diff --git a/tests/lrp-leverage.test.ts b/tests/lrp/lrp-leverage.test.ts similarity index 98% rename from tests/lrp-leverage.test.ts rename to tests/lrp/lrp-leverage.test.ts index b27b61a..4aef721 100644 --- a/tests/lrp-leverage.test.ts +++ b/tests/lrp/lrp-leverage.test.ts @@ -10,7 +10,7 @@ import { parseInterestOracleDatum, parsePriceOracleDatum, SystemParams, -} from '../src'; +} from '../../src'; import { addAssets, Emulator, @@ -21,31 +21,31 @@ import { toText, UTxO, } from '@lucid-evolution/lucid'; -import { findAllNecessaryOrefs, findCdp } from './queries/cdp-queries'; -import { LucidContext, runAndAwaitTx } from './test-helpers'; +import { findAllNecessaryOrefs, findCdp } from '../queries/cdp-queries'; +import { LucidContext, runAndAwaitTx } from '../test-helpers'; import { describe } from 'vitest'; import { assetClassValueOf, lovelacesAmt, mkLovelacesOf, -} from '../src/utils/value-helpers'; -import { init } from './endpoints/initialize'; -import { iusdInitialAssetCfg } from './mock/assets-mock'; -import { findAllLrps } from './queries/lrp-queries'; -import { ocdFloor, OnChainDecimal } from '../src/types/on-chain-decimal'; -import { assertValueInRange } from './utils/asserts'; +} from '../../src/utils/value-helpers'; +import { init } from '../endpoints/initialize'; +import { iusdInitialAssetCfg } from '../mock/assets-mock'; +import { findAllLrps } from './lrp-queries'; +import { ocdFloor, OnChainDecimal } from '../../src/types/on-chain-decimal'; +import { assertValueInRange } from '../utils/asserts'; import { calculateLeverageFromCollateralRatio, MAX_REDEMPTIONS_WITH_CDP_OPEN, -} from '../src/contracts/leverage/helpers'; -import { leverageCdpWithLrp } from '../src/contracts/leverage/transactions'; +} from '../../src/contracts/leverage/helpers'; +import { leverageCdpWithLrp } from '../../src/contracts/leverage/transactions'; import { calculateTotalAdaForRedemption, lrpRedeemableLovelacesInclReimb, MIN_LRP_COLLATERAL_AMT, randomLrpsSubsetSatisfyingTargetLovelaces, -} from '../src/contracts/lrp/helpers'; +} from '../../src/contracts/lrp/helpers'; type MyContext = LucidContext<{ admin: EmulatorAccount; diff --git a/tests/queries/lrp-queries.ts b/tests/lrp/lrp-queries.ts similarity index 100% rename from tests/queries/lrp-queries.ts rename to tests/lrp/lrp-queries.ts diff --git a/tests/lrp.test.ts b/tests/lrp/lrp.test.ts similarity index 87% rename from tests/lrp.test.ts rename to tests/lrp/lrp.test.ts index 9e29464..bc69474 100644 --- a/tests/lrp.test.ts +++ b/tests/lrp/lrp.test.ts @@ -11,28 +11,33 @@ import { toText, UTxO, } from '@lucid-evolution/lucid'; -import { parseLrpDatumOrThrow } from '../src/contracts/lrp/types'; +import { parseLrpDatumOrThrow } from '../../src/contracts/lrp/types'; import { adjustLrp, cancelLrp, claimLrp, openLrp, redeemLrp, -} from '../src/contracts/lrp/transactions'; -import { findLrp } from './queries/lrp-queries'; -import { addrDetails, getInlineDatumOrThrow } from '../src/utils/lucid-utils'; -import { LucidContext, runAndAwaitTx } from './test-helpers'; -import { matchSingle } from '../src/utils/utils'; -import { AssetClass, openCdp, SystemParams } from '../src'; +} from '../../src/contracts/lrp/transactions'; +import { findLrp } from './lrp-queries'; +import { + addrDetails, + getInlineDatumOrThrow, +} from '../../src/utils/lucid-utils'; +import { LucidContext, runAndAwaitTx } from '../test-helpers'; +import { matchSingle } from '../../src/utils/utils'; +import { AssetClass, openCdp, SystemParams } from '../../src'; import { assetClassValueOf, lovelacesAmt, mkLovelacesOf, -} from '../src/utils/value-helpers'; +} from '../../src/utils/value-helpers'; import { strictEqual } from 'assert'; -import { init } from './endpoints/initialize'; -import { iusdInitialAssetCfg } from './mock/assets-mock'; -import { findAllNecessaryOrefs } from './queries/cdp-queries'; +import { init } from '../endpoints/initialize'; +import { iusdInitialAssetCfg } from '../mock/assets-mock'; +import { findAllNecessaryOrefs } from '../queries/cdp-queries'; +import { redeemLrpMutated } from './transactions-mutated'; +import { expectScriptFailure } from '../utils/asserts'; type MyContext = LucidContext<{ admin: EmulatorAccount; @@ -434,6 +439,76 @@ describe('LRP', () => { ); }); + test('redemption without adaptive OCD replace fails', async (context: MyContext) => { + context.lucid.selectWallet.fromSeed(context.users.admin.seedPhrase); + + const [sysParams, __] = await init(context.lucid, [iusdInitialAssetCfg]); + + const iasset = fromText(iusdInitialAssetCfg.name); + + const [ownPkh, _] = await addrDetails(context.lucid); + + { + const orefs = await findAllNecessaryOrefs( + context.lucid, + sysParams, + toText(iasset), + ); + + await runAndAwaitTx( + context.lucid, + openCdp( + 100_000_000n, + 30_000_000n, + sysParams, + orefs.cdpCreatorUtxo, + orefs.iasset.utxo, + orefs.priceOracleUtxo, + orefs.interestOracleUtxo, + orefs.collectorUtxo, + context.lucid, + context.emulator.slot, + ), + ); + } + + await runAndAwaitTx( + context.lucid, + openLrp( + iasset, + 20_000_000n, + { getOnChainInt: 1_000_000n }, + context.lucid, + sysParams, + ), + true, + ); + + const lrpUtxo = await findSingleLrp(context, sysParams, iasset, ownPkh); + + const redemptionIAssetAmt = 11_000_000n; + + { + const orefs = await findAllNecessaryOrefs( + context.lucid, + sysParams, + toText(iasset), + ); + + await expectScriptFailure( + 'Wrong continuing output', + redeemLrpMutated( + [[lrpUtxo, redemptionIAssetAmt]], + orefs.priceOracleUtxo, + orefs.iasset.utxo, + context.lucid, + sysParams, + { type: 'ignore-adaptive-replace' }, + ), + ); + } + }); + test('redeem, redeem again and cancel', async (context: MyContext) => { context.lucid.selectWallet.fromSeed(context.users.admin.seedPhrase); diff --git a/tests/lrp/transactions-mutated.ts b/tests/lrp/transactions-mutated.ts new file mode 100644 index 0000000..e32e672 --- /dev/null +++ b/tests/lrp/transactions-mutated.ts @@ -0,0 +1,212 @@ +import { + LucidEvolution, + TxBuilder, + OutRef, + unixTimeToSlot, + slotToUnixTime, + UTxO, + addAssets, +} from '@lucid-evolution/lucid'; +import { unzip, zip } from 'fp-ts/lib/Array'; +import { + getInlineDatumOrThrow, + parsePriceOracleDatum, + parseIAssetDatumOrThrow, + matchSingle, + fromSystemParamsScriptRef, + SystemParams, + serialiseLrpDatum, + parseLrpDatumOrThrow, + serialiseLrpRedeemer, + lovelacesAmt, + MIN_LRP_COLLATERAL_AMT, + mkLovelacesOf, + mkAssetsOf, +} from '../../src'; +import { ocdMul, OnChainDecimal } from '../../src/types/on-chain-decimal'; +import { match, P } from 'ts-pattern'; +import { array as A, function as F } from 'fp-ts'; +import { calculateFeeFromPercentage } from '../../src/utils/indigo-helpers'; + +export function buildRedemptionsTx( + /** The tuple represents the LRP UTXO and the amount of iAssets to redeem against it. */ + redemptions: [UTxO, bigint][], + price: OnChainDecimal, + redemptionReimbursementPercentage: OnChainDecimal, + sysParams: SystemParams, + tx: TxBuilder, + /** + * The number of Tx outputs before these. + */ + txOutputsBeforeCount: bigint, + withoutAdaptiveReplace: boolean = false, +): TxBuilder { + const [[mainLrpUtxo, _], __] = match(redemptions) + .with( + [P._, ...P.array()], + ([[firstLrp, _], ...rest]): [[UTxO, bigint], [UTxO, bigint][]] => [ + [firstLrp, _], + rest, + ], + ) + .otherwise(() => { + throw new Error('Expects at least 1 UTXO to redeem.'); + }); + + const mainLrpDatum = parseLrpDatumOrThrow(getInlineDatumOrThrow(mainLrpUtxo)); + + return F.pipe( + redemptions, + A.reduceWithIndex<[UTxO, bigint], TxBuilder>( + tx, + (idx, acc, [lrpUtxo, redeemIAssetAmt]) => { + const lovelacesForRedemption = ocdMul( + { + getOnChainInt: redeemIAssetAmt, + }, + price, + ).getOnChainInt; + const reimburstmentLovelaces = calculateFeeFromPercentage( + redemptionReimbursementPercentage, + lovelacesForRedemption, + ); + + const lrpRawInlineDatum = getInlineDatumOrThrow(lrpUtxo); + const lrpDatum = parseLrpDatumOrThrow(lrpRawInlineDatum); + + const resultVal = addAssets( + lrpUtxo.assets, + mkLovelacesOf(-lovelacesForRedemption + reimburstmentLovelaces), + mkAssetsOf( + { + currencySymbol: + sysParams.lrpParams.iassetPolicyId.unCurrencySymbol, + tokenName: mainLrpDatum.iasset, + }, + redeemIAssetAmt, + ), + ); + + if (lovelacesAmt(resultVal) < MIN_LRP_COLLATERAL_AMT) { + throw new Error('LRP was incorrectly initialised.'); + } + + return acc + .collectFrom( + [lrpUtxo], + serialiseLrpRedeemer( + idx === 0 + ? { Redeem: { continuingOutputIdx: txOutputsBeforeCount + 0n } } + : { + RedeemAuxiliary: { + continuingOutputIdx: txOutputsBeforeCount + BigInt(idx), + mainRedeemOutRef: { + txHash: { hash: mainLrpUtxo.txHash }, + outputIndex: BigInt(mainLrpUtxo.outputIndex), + }, + asset: mainLrpDatum.iasset, + assetPrice: price, + redemptionReimbursementPercentage: + redemptionReimbursementPercentage, + }, + }, + ), + ) + .pay.ToContract( + lrpUtxo.address, + { + kind: 'inline', + value: serialiseLrpDatum( + { + ...lrpDatum, + lovelacesToSpend: + lrpDatum.lovelacesToSpend - lovelacesForRedemption, + }, + withoutAdaptiveReplace + ? undefined + : { + _tag: 'adaptiveReplace', + spentLrpDatum: lrpRawInlineDatum, + }, + ), + }, + resultVal, + ); + }, + ), + ); +} + +export type RedeemLrpMutatedType = + | { type: 'no-mutations' } + | { type: 'ignore-adaptive-replace' }; + +export async function redeemLrpMutated( + /** The tuple represents the LRP outref and the amount of iAssets to redeem against it. */ + redemptionLrpsData: [OutRef, bigint][], + priceOracleOutRef: OutRef, + iassetOutRef: OutRef, + lucid: LucidEvolution, + sysParams: SystemParams, + redeemLrpMutatedType: RedeemLrpMutatedType = { type: 'no-mutations' }, +): Promise { + const network = lucid.config().network!; + + const lrpScriptRefUtxo = matchSingle( + await lucid.utxosByOutRef([ + fromSystemParamsScriptRef(sysParams.scriptReferences.lrpValidatorRef), + ]), + (_) => new Error('Expected a single LRP Ref Script UTXO'), + ); + + const priceOracleUtxo = matchSingle( + await lucid.utxosByOutRef([priceOracleOutRef]), + (_) => new Error('Expected a single price oracle UTXO'), + ); + + const iassetUtxo = matchSingle( + await lucid.utxosByOutRef([iassetOutRef]), + (_) => new Error('Expected a single IAsset UTXO'), + ); + + const iassetDatum = parseIAssetDatumOrThrow( + getInlineDatumOrThrow(iassetUtxo), + ); + + const [lrpsToRedeemOutRefs, lrpRedemptionIAssetAmt] = + unzip(redemptionLrpsData); + + const priceOracleDatum = parsePriceOracleDatum( + getInlineDatumOrThrow(priceOracleUtxo), + ); + + const redemptionLrps = await lucid + .utxosByOutRef(lrpsToRedeemOutRefs) + .then((val) => zip(val, lrpRedemptionIAssetAmt)); + + const tx = buildRedemptionsTx( + redemptionLrps, + priceOracleDatum.price, + iassetDatum.redemptionReimbursementPercentage, + sysParams, + lucid.newTx(), + 0n, + redeemLrpMutatedType.type === 'ignore-adaptive-replace', + ); + + return ( + lucid + .newTx() + .validTo( + slotToUnixTime( + network, + unixTimeToSlot(network, Number(priceOracleDatum.expiration)) - 1, + ), + ) + // Ref script + .readFrom([lrpScriptRefUtxo]) + // Ref inputs + .readFrom([iassetUtxo, priceOracleUtxo]) + .compose(tx) + ); +} diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 098a431..bb4897d 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -14,9 +14,10 @@ export type LucidContext> = { export async function runAndAwaitTx( lucid: LucidEvolution, transaction: Promise, + canonical: boolean = false, ): Promise { const txHash = await transaction - .then((tx) => tx.complete()) + .then((tx) => tx.complete({ canonical })) .then((tx) => tx.sign.withWallet().complete()) .then((tx) => tx.submit()); diff --git a/tests/utils/asserts.ts b/tests/utils/asserts.ts index 66e2cd0..2b7f774 100644 --- a/tests/utils/asserts.ts +++ b/tests/utils/asserts.ts @@ -1,5 +1,35 @@ +import { TxBuilder } from '@lucid-evolution/lucid'; +import { match, P } from 'ts-pattern'; import { assert, expect } from 'vitest'; +export async function expectScriptFailure( + /** + * This doesn't have to be full message, can be just a part of it. + */ + contains: string, + tx: Promise, +): Promise { + if (contains.length === 0) { + throw new Error('Expected error message has to be non empty.'); + } + + const result = await (await tx).completeSafe(); + + const errMsg = match(result) + .with({ _tag: 'Left', left: P.select() }, (smth) => smth.message) + .otherwise(() => null); + + if (!errMsg) { + throw new Error(`Expected TX to fail, but it succeeded.`); + } + + if (!errMsg.includes(contains)) { + throw new Error( + `Expected TX to fail with error containing: "${contains}". But got: "${errMsg}"`, + ); + } +} + export function assertValueInRange( val: T, bounds: { min: T; max: T },