Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(run-protocol): distinguish LIQUIDATED from CLOSED vault state #4640

Merged
merged 4 commits into from
Feb 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want a state machine...then we have a library for it, and can potentially integrate that with the enum proposal

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. I was considering a fuller state machine with transition functions and figured this was a good incremental step that wasn't entirely out of scope for the ticket :)

I'd like to hear more about the library we have and this enum proposal and consider refactoring this in the future to use them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not much: .../zoe/src/contractSupport/stateMachine.js. It started approximately like this. Just enough for present purposes, with a hope for more when we need it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the link. I don't think that version is going to work well for state that need to be serialized into virtual objects because the stateMachine assumes the state is in its own closure and it's got to be in the state object.

We could isolate the actual state from the transition mechanics with a design like https://kentcdodds.com/blog/implementing-a-simple-state-machine-library-in-javascript that would allow:

state.phase = machine.transition(state.phase, 'liquidate')

However I think that's out of scope of this PR and the assignState method closes the gap to a full machine if that proves worthwhile.

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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add TRANSFERABLE here.

If no tests broke, then there aren't any tests for transfering. Please add some tests in that case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are tests for transferring. "Transfer" is only visible in finish event that is generated when the transfer is started

Copy link
Member Author

@turadg turadg Feb 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a smell though that the transfer case is so special. Refactored in 29b997

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hit some snags so intend to punt fcdd7a0 until #4415

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);
});