Skip to content

Commit

Permalink
Merge pull request #4640 from Agoric/ta/4539-liquidated-vs-closed
Browse files Browse the repository at this point in the history
feat(run-protocol): distinguish LIQUIDATED from CLOSED vault state
  • Loading branch information
mergify[bot] authored Feb 24, 2022
2 parents 05c3060 + 59ee9b0 commit f8486f4
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 42 deletions.
3 changes: 1 addition & 2 deletions packages/run-protocol/src/vaultFactory/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@
* @typedef {Object} LiquidationUIMixin
* @property {Ratio} interestRate Annual interest rate charge
* @property {Ratio} liquidationRatio
* @property {boolean} liquidated boolean showing whether liquidation occurred
* @property {'active' | 'liquidating' | 'closed'} vaultState
* @property {'active' | 'liquidating' | 'liquidated' | 'closed' | 'transfer'} vaultState
*/

/**
Expand Down
93 changes: 68 additions & 25 deletions packages/run-protocol/src/vaultFactory/vault.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,38 @@ const trace = makeTracer('Vault');
// collateral, and lending RUN to the borrower

/**
* Constants for vault state.
* Constants for vault phase.
*
* @typedef {'active' | 'liquidating' | 'closed' | 'transfer'} VAULT_STATE
* ACTIVE - vault is in use and can be changed
* LIQUIDATING - vault is being liquidated by the vault manager, and cannot be changed by the user
* TRANSFER - vault is released from the manager and able to be transferred
* TRANSFER - vault is able to be transferred (payments and debits frozen until it has a new owner)
* CLOSED - vault was closed by the user and all assets have been paid out
* LIQUIDATED - vault was closed by the manager, with remaining assets paid to owner
*
* @type {{ ACTIVE: 'active', LIQUIDATING: 'liquidating', CLOSED: 'closed', TRANSFER: 'transfer' }}
* @typedef {VaultPhase[keyof typeof VaultPhase]} VAULT_PHASE
*/
export const VaultState = {
export const VaultPhase = /** @type {const} */ ({
ACTIVE: 'active',
LIQUIDATING: 'liquidating',
CLOSED: 'closed',
LIQUIDATED: 'liquidated',
TRANSFER: 'transfer',
});

/**
* @type {{[K in VAULT_PHASE]: Array<VAULT_PHASE>}}
*/
const validTransitions = {
[VaultPhase.ACTIVE]: [
VaultPhase.LIQUIDATING,
VaultPhase.TRANSFER,
VaultPhase.CLOSED,
],
[VaultPhase.LIQUIDATING]: [VaultPhase.LIQUIDATED],
[VaultPhase.TRANSFER]: [VaultPhase.ACTIVE, VaultPhase.LIQUIDATING],
[VaultPhase.LIQUIDATED]: [],
[VaultPhase.CLOSED]: [],
};

const makeOuterKit = inner => {
Expand Down Expand Up @@ -102,12 +123,31 @@ export const makeInnerVault = (
const { zcfSeat: liquidationZcfSeat, userSeat: liquidationSeat } =
zcf.makeEmptySeatKit(undefined);

/** @type {VAULT_STATE} */
let vaultState = VaultState.ACTIVE;
// #region Phase state
/** @type {VAULT_PHASE} */
let phase = VaultPhase.ACTIVE;

/**
* @param {VAULT_PHASE} newPhase
*/
const assignPhase = newPhase => {
const validNewPhases = validTransitions[phase];
if (!validNewPhases.includes(newPhase))
throw new Error(`Vault cannot transition from ${phase} to ${newPhase}`);
phase = newPhase;
};

const assertPhase = allegedPhase => {
assert(
phase === allegedPhase,
X`vault must be ${allegedPhase}, not ${phase}`,
);
};

const assertVaultIsOpen = () => {
assert(vaultState === VaultState.ACTIVE, X`vault must still be active`);
assertPhase(VaultPhase.ACTIVE);
};
// #endregion

let outerUpdater;

Expand Down Expand Up @@ -239,7 +279,7 @@ export const makeInnerVault = (
: getCollateralAllocated(vaultSeat);
};

const snapshotState = vstate => {
const snapshotState = newPhase => {
/** @type {VaultUIState} */
return harden({
// TODO move manager state to a separate notifer https://github.com/Agoric/agoric-sdk/issues/4540
Expand All @@ -248,38 +288,44 @@ export const makeInnerVault = (
debtSnapshot,
locked: getCollateralAmount(),
debt: getDebtAmount(),
// TODO state distinct from CLOSED https://github.com/Agoric/agoric-sdk/issues/4539
liquidated: vaultState === VaultState.CLOSED,
vaultState: vstate,
// newPhase param is so that makeTransferInvitation can finish without setting the vault's phase
// TODO refactor https://github.com/Agoric/agoric-sdk/issues/4415
vaultState: newPhase,
});
};

// call this whenever anything changes!
const updateUiState = () => {
if (!outerUpdater) {
console.warn('updateUiState called after outerUpdater removed');
return;
}
/** @type {VaultUIState} */
const uiState = snapshotState(vaultState);
const uiState = snapshotState(phase);
trace('updateUiState', uiState);

switch (vaultState) {
case VaultState.ACTIVE:
case VaultState.LIQUIDATING:
switch (phase) {
case VaultPhase.ACTIVE:
case VaultPhase.LIQUIDATING:
outerUpdater.updateState(uiState);
break;
case VaultState.CLOSED:
case VaultPhase.CLOSED:
case VaultPhase.LIQUIDATED:
outerUpdater.finish(uiState);
outerUpdater = null;
break;
case VaultPhase.TRANSFER:
// Transfer handles finish()/null itself
throw Error('no UI updates from transfer state');
default:
throw Error(`unreachable vaultState: ${vaultState}`);
throw Error(`unreachable vault phase: ${phase}`);
}
};
// XXX Echo notifications from the manager through all vaults
// TODO move manager state to a separate notifer https://github.com/Agoric/agoric-sdk/issues/4540
observeNotifier(managerNotifier, {
updateState: () => {
if (vaultState !== VaultState.CLOSED) {
if (phase !== VaultPhase.CLOSED) {
updateUiState();
}
},
Expand All @@ -293,15 +339,12 @@ export const makeInnerVault = (
const liquidated = newDebt => {
updateDebtSnapshot(newDebt);

vaultState = VaultState.CLOSED;
assignPhase(VaultPhase.LIQUIDATED);
updateUiState();
};

const liquidating = () => {
if (vaultState === VaultState.LIQUIDATING) {
throw new Error('Vault already liquidating');
}
vaultState = VaultState.LIQUIDATING;
assignPhase(VaultPhase.LIQUIDATING);
updateUiState();
};

Expand Down Expand Up @@ -342,7 +385,7 @@ export const makeInnerVault = (
runMint.burnLosses(harden({ RUN: currentDebt }), burnSeat);
seat.exit();
burnSeat.exit();
vaultState = VaultState.CLOSED;
assignPhase(VaultPhase.CLOSED);
updateDebtSnapshot(AmountMath.makeEmpty(runBrand));
updateUiState();

Expand Down Expand Up @@ -670,7 +713,7 @@ export const makeInnerVault = (
makeCloseInvitation,
makeTransferInvitation: () => {
if (outerUpdater) {
outerUpdater.finish(snapshotState(VaultState.TRANSFER));
outerUpdater.finish(snapshotState(VaultPhase.TRANSFER));
outerUpdater = null;
}
return zcf.makeInvitation(makeTransferInvitationHook, 'TransferVault');
Expand Down
37 changes: 22 additions & 15 deletions packages/run-protocol/test/vaultFactory/test-vaultFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {

import { makeTracer } from '../../src/makeTracer.js';
import { SECONDS_PER_YEAR } from '../../src/vaultFactory/interest.js';
import { VaultState } from '../../src/vaultFactory/vault.js';
import {
CHARGING_PERIOD_KEY,
RECORDING_PERIOD_KEY,
Expand All @@ -44,6 +43,15 @@ const trace = makeTracer('TestST');

const BASIS_POINTS = 10000n;

// Define locally to test that vaultFactory uses these values
export const Phase = /** @type {const} */ ({
ACTIVE: 'active',
LIQUIDATING: 'liquidating',
CLOSED: 'closed',
LIQUIDATED: 'liquidated',
TRANSFER: 'transfer',
});

async function makeBundle(sourceRoot) {
const url = await importMetaResolve(sourceRoot, import.meta.url);
const path = new URL(url).pathname;
Expand Down Expand Up @@ -508,7 +516,7 @@ test('price drop', async t => {
let notification = await E(uiNotifier).getUpdateSince();
trace('got notificaation', notification);

t.falsy(notification.value.liquidated);
t.is(notification.value.vaultState, Phase.ACTIVE);
t.deepEqual(await notification.value.debt, AmountMath.add(loanAmount, fee));
const { RUN: lentAmount } = await E(loanSeat).getCurrentAllocation();
t.truthy(AmountMath.isEqual(lentAmount, loanAmount), 'received 470 RUN');
Expand All @@ -519,22 +527,21 @@ test('price drop', async t => {
);
await manualTimer.tick();
notification = await E(uiNotifier).getUpdateSince();
t.falsy(notification.value.liquidated);
t.is(notification.value.vaultState, Phase.ACTIVE);

await manualTimer.tick();
notification = await E(uiNotifier).getUpdateSince(notification.updateCount);
trace('price changed to liquidate', notification.value.vaultState);
t.falsy(notification.value.liquidated);
t.is(notification.value.vaultState, VaultState.LIQUIDATING);
t.is(notification.value.vaultState, Phase.LIQUIDATING);

await manualTimer.tick();
notification = await E(uiNotifier).getUpdateSince(notification.updateCount);
t.falsy(notification.updateCount);
t.truthy(notification.value.liquidated);
t.is(notification.value.vaultState, Phase.LIQUIDATED);

const debtAmountAfter = await E(vault).getDebtAmount();
const finalNotification = await E(uiNotifier).getUpdateSince();
t.truthy(finalNotification.value.liquidated);
t.is(finalNotification.value.vaultState, Phase.LIQUIDATED);
t.truthy(AmountMath.isEmpty(debtAmountAfter));

t.deepEqual(await E(vaultFactory).getRewardAllocation(), {
Expand Down Expand Up @@ -1270,14 +1277,14 @@ test('transfer vault', async t => {
const aliceFinish = await E(aliceNotifier).getUpdateSince();
t.deepEqual(
aliceFinish.value.vaultState,
VaultState.TRANSFER,
Phase.TRANSFER,
'transfer closed old notifier',
);

const transferStatus = await E(transferNotifier).getUpdateSince();
t.deepEqual(
transferStatus.value.vaultState,
VaultState.ACTIVE,
Phase.ACTIVE,
'new notifier is active',
);

Expand Down Expand Up @@ -1322,14 +1329,14 @@ test('transfer vault', async t => {
const transferFinish = await E(transferNotifier).getUpdateSince();
t.deepEqual(
transferFinish.value.vaultState,
VaultState.TRANSFER,
Phase.TRANSFER,
't2 closed old notifier',
);

const t2Status = await E(t2Notifier).getUpdateSince();
t.deepEqual(
t2Status.value.vaultState,
VaultState.ACTIVE,
Phase.ACTIVE,
'new notifier is active',
);
});
Expand Down Expand Up @@ -1657,15 +1664,15 @@ test('mutable liquidity triggers and interest', async t => {
await waitForPromisesToSettle();
aliceUpdate = await E(aliceNotifier).getUpdateSince(aliceUpdate.updateCount);
bobUpdate = await E(bobNotifier).getUpdateSince();
t.truthy(aliceUpdate.value.liquidated);
t.is(aliceUpdate.value.vaultState, Phase.LIQUIDATED);

for (let i = 0; i < 5; i += 1) {
manualTimer.tick();
}
await waitForPromisesToSettle();
bobUpdate = await E(bobNotifier).getUpdateSince();

t.truthy(bobUpdate.value.liquidated);
t.is(bobUpdate.value.vaultState, Phase.LIQUIDATED);
});

test('bad chargingPeriod', async t => {
Expand Down Expand Up @@ -2159,13 +2166,13 @@ test('mutable liquidity triggers and interest sensitivity', async t => {
await waitForPromisesToSettle();
aliceUpdate = await E(aliceNotifier).getUpdateSince(aliceUpdate.updateCount);
bobUpdate = await E(bobNotifier).getUpdateSince();
t.falsy(aliceUpdate.value.liquidated);
t.is(aliceUpdate.value.vaultState, Phase.ACTIVE);

for (let i = 0; i < 5; i += 1) {
manualTimer.tick();
}
await waitForPromisesToSettle();
bobUpdate = await E(bobNotifier).getUpdateSince();

t.truthy(bobUpdate.value.liquidated);
t.is(bobUpdate.value.vaultState, Phase.LIQUIDATED);
});

0 comments on commit f8486f4

Please sign in to comment.