From 69a90400e0f4597e0346a2c875517560f13cb2ef Mon Sep 17 00:00:00 2001 From: JorgeLopes-BytePitch Date: Fri, 12 Jan 2024 11:16:22 +0000 Subject: [PATCH 01/12] # This is a combination of 22 commits. # This is the 1st commit message: create dedicated files for test assertions and tools # This is the commit message #2: Add new test file for liquidation visibility # This is the commit message #3: auctioneer snapshot # This is the commit message #4: Update visibility tests and helper functions # This is the commit message #5: add tools and assertions to match subscriber with vstorage data # This is the commit message #6: update test visibility of vault liquidation # This is the commit message #7: fix(liquidationVisibility): fix linting errors # This is the commit message #8: fix(liquidationVisibility): type error # This is the commit message #9: chore(liquidationVisibility): #4 testing tools setup # This is the commit message #10: chore(liquidationVisibility): #4 improve testing tools # This is the commit message #11: chore(liquidationVisibility): #4 improve testing tools # This is the commit message #12: fix(liquidationVisibility): linting fixes # This is the commit message #13: chore(liquidationVisibility): fix test names # This is the commit message #14: fix(liquidationVisibility): lint fix # This is the commit message #15: chore(liquidationVisibility): #4 implement `assertNodeInStorage` # This is the commit message #16: fix(liquidationVisibility): #4 lint fix # This is the commit message #17: chore(liquidationVisibility): #4 test skeleton is ready # This is the commit message #18: chore(liquidationVisibility): #4 add marshaller for comparing data from the vstorage # This is the commit message #19: chore(liquidationVisibility): sample test for `preAuction` and `postAuction` data fields # This is the commit message #20: chore(liquidationVisibility): make sure `assertStorageData` works # This is the commit message #21: chore(liquidationVisibility): #4 add test for case 2b, uncomment assertions for running the tests # This is the commit message #22: feat(liquidationVisibility): create liquidation storageNodes and recorderKits BREAKING CHANGE: Introduced the `_timestamp` as an argument for the vaultManager liquidateVaults method. feat(liquidationVisibility): write preAuctionState and auctionResultState to Vstorage BREAKING CHANGE: a getVaultId method was included in the Vault interface, which returns the vault `idInManager` fix(liquidationVisibility): fix type definitions errors and concurrently await multiple promises feat(liquidationVisibility): write postAuctionState to Vstorage BREAKING CHANGE: the getVaultId method of vaults interface was updated to getVaultState, which will return the vault phase as well chore(liquidationVisibility): update helper methods, type definitions and names fix(liquidationVisibility): lint fix fix(liquidationVisibility): update sequence to write auction state to Vstorage and its structure The helper methods built for this purpose became unnecessary and were removed. fix(liquidationVisibility): update postAuctionState structure and change to writeFinal recorder method fix(liquidationVisibility): remove temporary changes made to tests fix(liquidationVisibility): #4 test for scenario 2b passed, test api updated, #7 is fixed chore(liquidationVisibility): #4 test for scenario 2a passed chore(liquidationVisibility): #4 update scenario 1 Squashed commit of the following: commit 728d69557b63ea81adbfa97d721d2dffe0ccbfdb Author: anilhelvaci Date: Wed Jan 31 14:28:14 2024 +0300 chore(liquidationVisibility): uncomment post auction assertion in `liq-result-scenario-1` commit dd3fbdbbf0331fcb978f9c457006a3120fadd9aa Author: anilhelvaci Date: Wed Jan 31 14:25:22 2024 +0300 fix(liquidationVisibility): lint fix commit 6920d1a522e49faeb56b11d0dcf8bc9d2a934360 Author: anilhelvaci Date: Wed Jan 31 14:22:28 2024 +0300 fix(liquidationVisibility): explain Promise.allSettled commit 732e1d7e7794937c2a9d4b5aff1f2d61b3d0ba92 Author: anilhelvaci Date: Wed Jan 31 11:37:45 2024 +0300 feat(liquidationVisibility): handle errors that might arise from other vats commit 683f56d0bd474483e74b4ac8709493c90c07d274 Author: anilhelvaci Date: Tue Jan 30 14:31:13 2024 +0300 feat(liquidationVisibility): add LiquidationVisibilityWriters to improve readability, fetch schedule during the auction itself commit 4c45f2a7730ebb05a2a4fa355debf82cb00c57a6 Author: anilhelvaci Date: Tue Jan 30 10:30:02 2024 +0300 fix(liquidationVisibility): add pattern matcher to `getVaultState` chore(liquidationVisibility): #4 add auctioneer wrapper and update setupBasics chore(liquidationVisibility): #4 add mock makeChainStorageNode chore(liquidationVisibility): #4 add tests for no vaults and rejected schedule fix(liquidationVisibility): #4 add setBlockMakeChildNode method and update file name fix(liquidationVisibility): #4 update import path chore(liquidationVisibility): #4 add liq-rejected-timestampStorageNode test fix(liquidationVisibility): #4 fix bug with at makeChildNode fix(liquidationVisibility): #4 update test names and comments fix(liquidationVisibility): #4 lint fix chore(internal): create key-value pairs for Promise.allSettled values fix(internal): make allValuesSettled a mapper for resolved promises and silently handles rejected ones fix(internal): fix doc fix(liquidationVisibility): make sure promises are assigned in key-value fashion, lint fixes. chore(liquidationVisibility): extend liq-rejected-timestampStorageNode and clean outdated comments fix(liquidationVisibility): revert update made to package.json chore(liquidationVisibility): update snapshot generated by unit tests fix(liquidationVisibility): lint fix chore(liquidationVisibility) #4 test multiple vaultManagers fix(liquidationVisibility): #4 lint fix fix(liquidationVisibility): add pattern matcher to `getVaultState` feat(liquidationVisibility): add LiquidationVisibilityWriters to improve readability, fetch schedule during the auction itself chore(liquidationVisibility): init work for bootstrap tests chore(liquidationVisibility): created test-liquidation-visibility.ts and started building a test suite chore(liquidationVisibility): upgrade tests are implemented. Refs: #15 fix(liquidationVisibility): vaults are now displayed in the correct order at `vaults.preAuction` Refs: #13 fix(liquidationVisibility): vault phases are now displayed correctly at `vaults.postAuction` Refs: #14 chore(liquidationVisibility): add storage snapshot Refs: #15 fix(liquidationVisibility): don't reverse `vaultData` for preAuction storage node Refs: #13 --- .../test-liquidation-visibility.ts.md | 472 ++++++ .../test-liquidation-visibility.ts.snap | Bin 0 -> 2400 bytes packages/boot/tools/liquidation.ts | 426 +++++ .../src/vaultFactory/liquidation.js | 16 +- .../inter-protocol/src/vaultFactory/types.js | 30 +- .../inter-protocol/src/vaultFactory/vault.js | 10 + .../src/vaultFactory/vaultDirector.js | 3 +- .../src/vaultFactory/vaultManager.js | 263 ++- .../test/liquidationVisibility/assertions.js | 268 +++ .../auctioneer-contract-wrapper.js | 752 +++++++++ .../mock-setupChainStorage.js | 568 +++++++ .../test-liquidationVisibility.js.md | 603 +++++++ .../test-liquidationVisibility.js.snap | Bin 0 -> 1918 bytes .../test-liquidationVisibility.js | 1476 +++++++++++++++++ .../test-visibilityAssertions.js | 92 + .../test/liquidationVisibility/tools.js | 480 ++++++ .../vaultFactory/test-vaultLiquidation.js | 3 + packages/internal/src/utils.js | 15 + packages/internal/test/test-utils.js | 15 + .../test-liquidation-visibility.ts | 442 +++++ 20 files changed, 5914 insertions(+), 20 deletions(-) create mode 100644 packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md create mode 100644 packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap create mode 100644 packages/boot/tools/liquidation.ts create mode 100644 packages/inter-protocol/test/liquidationVisibility/assertions.js create mode 100644 packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js create mode 100644 packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js create mode 100644 packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md create mode 100644 packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.snap create mode 100644 packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js create mode 100644 packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js create mode 100644 packages/inter-protocol/test/liquidationVisibility/tools.js create mode 100644 packages/vats/test/bootstrapTests/test-liquidation-visibility.ts diff --git a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md new file mode 100644 index 00000000000..51c2a235df5 --- /dev/null +++ b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md @@ -0,0 +1,472 @@ +# Snapshot report for `test/bootstrapTests/test-liquidation-visibility.ts` + +The actual snapshot is saved in `test-liquidation-visibility.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## snapshot-storage + +> Snapshot 1 + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 105525000n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 103515000n, + }, + }, + ], + [ + 'vault2', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 100500000n, + }, + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [ + [ + 'vault2', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3425146n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3077900n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault0', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 2846403n, + }, + phase: 'liquidated', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 309852n, + }, + collateralOffered: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 45000000n, + }, + collateralRemaining: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 35340699n, + }, + endTime: { + absValue: 5042n, + timerBrand: Object @Alleged: BoardRemotetimerBrand { + getBoardId: Function getBoardId {}, + }, + }, + istTarget: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + mintedProceeds: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + shortfallToReserve: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + }, + ], + ] + +> Snapshot 2 + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.10800.vaults.preAuction', + [ + [ + 'vault3', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 105525000n, + }, + }, + ], + [ + 'vault4', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 103515000n, + }, + }, + ], + [ + 'vault5', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 100500000n, + }, + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.10800.vaults.postAuction', + [ + [ + 'vault5', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3425146n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault4', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 3077900n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault3', + { + Collateral: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 2846403n, + }, + phase: 'liquidated', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.10800.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 309852n, + }, + collateralOffered: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 45000000n, + }, + collateralRemaining: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: BoardRemoteATOM brand { + getBoardId: Function getBoardId {}, + }, + value: 35340699n, + }, + endTime: { + absValue: 12242n, + timerBrand: Object @Alleged: BoardRemotetimerBrand { + getBoardId: Function getBoardId {}, + }, + }, + istTarget: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + mintedProceeds: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + shortfallToReserve: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + }, + ], + ] + +> Snapshot 3 + + [ + [ + 'published.vaultFactory.managers.manager1.liquidations.14400.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 105525000n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 103515000n, + }, + }, + ], + [ + 'vault2', + { + collateralAmount: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 15000000n, + }, + debtAmount: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 100500000n, + }, + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.14400.vaults.postAuction', + [ + [ + 'vault2', + { + Collateral: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 3425146n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 3077900n, + }, + phase: 'liquidated', + }, + ], + [ + 'vault0', + { + Collateral: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 2846403n, + }, + phase: 'liquidated', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.14400.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 309852n, + }, + collateralOffered: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 45000000n, + }, + collateralRemaining: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: BoardRemoteSTARS brand { + getBoardId: Function getBoardId {}, + }, + value: 35340699n, + }, + endTime: { + absValue: 15842n, + timerBrand: Object @Alleged: BoardRemotetimerBrand { + getBoardId: Function getBoardId {}, + }, + }, + istTarget: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + mintedProceeds: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 309540000n, + }, + shortfallToReserve: { + brand: Object @Alleged: BoardRemoteIST brand { + getBoardId: Function getBoardId {}, + }, + value: 0n, + }, + }, + ], + ] diff --git a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..b3b15a5a857e58b84a56b39474c996243111fae8 GIT binary patch literal 2400 zcmV-m37_^sRzV(cy;X5zC=rD#TZ+QwpD94Z7XU6qX}cM4-k}=cI#M~O4Th}nbxk_N7-mq z0h)knFjZr1w|~yDeSXi$bCOz&W^yFW$))2Jw9<(t@-h6?~D8w>Kve z;xNyrx4d32$GbVcfa8mKep9L#TiYhS^UCH=CN^yoEel@5=3kLUKDV7MryanR09ui~ z3|Ps6hX5Q!e70Qh26lp&KnVyNS2;0PyN_QzFxVB1B>SXr?T|Pal~;+~I4{Gs{bCF! zCy`8V2({73y@L^)ph!HHtgWjTgj7MYb|4`&4|Y%dUOCYDw$w4_8*PmRqs5c5dbrkU zhWng};l$J!?NSnj%1A?#BjKWW7MsVAmyuVQb6&u_IfZ`*@HTQ0xx&ELFtE#Bz;VTh zAL&Gf5nPYlA>?<+`GUy?MQvSQl)7d9#>{3}S()l0^cAKr!li|oi?F*ebrEK$Z)T_? zS?bgz<-v?NzLe)3b}n;HE4$;-s3=PbF}fWD{EO%AK}JET~+Gt!@$z$w)ZS0pKSiiuuHR(83HR6YA6 zFEufY-BN8-Vpl)WKYMH|1V< zKpz3}J>;h>`UwsCM<9Pj-eJ)y4eE7r+%-tGlR-bJLEC|BL>^$#PifE{Kz1QdvuL#j zeFMl_$Q2g7MT6ep;)KZ*Z;kJ`pK;aJSzZ?>ygOc(SU&0sF2~CZmfU3bxV-1|a zkf402T8tTM!K-Wc8yvnaR2LNLo0`%sa#*L>mE54l3qwLMpp0`+HI7@x4KA@FHCSdQ z7mdxtg)XOs@GCg*YJ$^&>_&3>OVJX$)b~Ujw z(}TUx2l~XMl;P4JDJAu)9kQ&`ZVNi`N~dvkCwyR1-se^|@c~H)-YEzyiZ1NyL$-LC ztE)(}e)a(QF>;(mT^jTpkc&uB8H1K;&}tw7q?JXz8gu~25b`jKmTS<%K%PZTvS@_{ zeHY04NTrWKuhpPUKyF1gu;_IfbTg2x$YU(JFc+OSaY5v~Eeh>iH5gO=i^DHg{h+U6 ziQ$S`wjy&OKkqa4NcC%<=*1&i215Q|y{f~bMmq=%4NW<7_`bmofyQ8cP^eR$W+SK_Eoat+L*LUC<;PI^ zB3tg(`!IAUukPG!bUlrZXV|XubU*UvrC&35-GU10z8oTn-l+(3NjnYS1d0t;d@Q#j zZRkcmBtzFk8M+n#w;}r|L-!Pb=a5RnhHi$=%r7)fspu9=3)ID6`@|SBeCY&m;FEPasE-lgK-IC0%7kzb(t^ zw;oo~eJev9%~GdXN$1uj=|Z%m`+^=x_YFeQJw27AJ3Y0eD z19$}4OG&yz0FENBP?GL10RDlzOG!E#?p;n~0VU~f1|T4xr6gTDfQ?9;mUQ0%vI99t zOS(S+`5SVHmUQJ#j=L7Qg_d;df!v8~r6t`6kWu70TGE{cat8T;mUN3<9Jd5nF=I(r zr>sDclx4w`V<)+g!abvtdsFAQ9#Yb!j-!#4blqkp-IL}e-H%L2x}VLeq_g`2fk{jE z6lv)$Szzh>7FfE58Ckkzs9K3gw58h$}FQZRuPk9Op%r(3Y+P$Q{Ta zZRtjUj3UQqOLqpyc`GfQ%Y!4DxusizQon_kZVhS2zC;?fQL@MH$sYe9dn_U?U7KZY z?LNyo?Xk?#og^KcZ7K(sA%fFbx*qI{BHL(7w-3kxAq{)(mig%((Oj=-kDpvU!(M8%Prm8==e`7EuDjOa7`p} z&(&z@elTb0G#bm#XslpxRxRDM!_9)F%RAnjrJJ*KbCzz-(tXI5PS9!T{5maNK&Pb( z=(Kcxot93RB}+GI-qP(eVd)OcvZV_(PFlJnq@}xTfu##rVCfoXWa*Zpss-txE!|gu zY)AIfmhJ?QapVGR>D(n8SB6y6maY@XCS-`VbUT5JA-|$6-B}seB`989DbT+ql zP@2x|!6D6D(^aE1V4t8$IYWP-Etr;$epyN`!bLRk&&xg(;YXj>1-BgI)i@+mv0&d{}3*(rZf1LaQUWT z@DJhgYPyZ4HQi&THQi1Vnr;lW`}1fzU5{7PpFr)n<(}>=I$p@*>GVGIwW7{VYPe66 SMC@u3b^iy8THw_kI{*Nfz>cv1 literal 0 HcmV?d00001 diff --git a/packages/boot/tools/liquidation.ts b/packages/boot/tools/liquidation.ts new file mode 100644 index 00000000000..907916f7788 --- /dev/null +++ b/packages/boot/tools/liquidation.ts @@ -0,0 +1,426 @@ +import { Fail } from '@agoric/assert'; +import { + SECONDS_PER_HOUR, + SECONDS_PER_MINUTE, +} from '@agoric/inter-protocol/src/proposals/econ-behaviors.js'; +import { + AgoricNamesRemotes, + makeAgoricNamesRemotesFromFakeStorage, +} from '@agoric/vats/tools/board-utils.js'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import type { ExecutionContext } from 'ava'; +import { type SwingsetTestKit, makeSwingsetTestKit } from './supports.js'; +import { + type GovernanceDriver, + type PriceFeedDriver, + type WalletFactoryDriver, + makeGovernanceDriver, + makePriceFeedDriver, + makeWalletFactoryDriver, +} from './drivers.js'; + +export type LiquidationSetup = { + vaults: { + atom: number; + ist: number; + debt: number; + }[]; + bids: ( + | { + give: string; + discount: number; + price?: undefined; + } + | { + give: string; + price: number; + discount?: undefined; + } + )[]; + price: { + starting: number; + trigger: number; + }; + auction: { + start: { + collateral: number; + debt: number; + }; + end: { + collateral: number; + debt: number; + }; + }; +}; + +export const scale6 = x => BigInt(Math.round(x * 1_000_000)); + +const DebtLimitValue = scale6(100_000); + +export const likePayouts = ({ Bid, Collateral }) => ({ + Collateral: { + value: scale6(Collateral), + }, + Bid: { + value: scale6(Bid), + }, +}); + +export const makeLiquidationTestKit = async ({ + swingsetTestKit, + agoricNamesRemotes, + walletFactoryDriver, + governanceDriver, + t, +}: { + swingsetTestKit: SwingsetTestKit; + agoricNamesRemotes: AgoricNamesRemotes; + walletFactoryDriver: WalletFactoryDriver; + governanceDriver: GovernanceDriver; + t: Pick; +}) => { + const priceFeedDrivers = {} as Record; + + console.timeLog('DefaultTestContext', 'priceFeedDriver'); + + console.timeEnd('DefaultTestContext'); + + const setupStartingState = async ({ + collateralBrandKey, + managerIndex, + price, + }: { + collateralBrandKey: string; + managerIndex: number; + price: number; + }) => { + const managerPath = `published.vaultFactory.managers.manager${managerIndex}`; + const { advanceTimeBy, readLatest } = swingsetTestKit; + + await null; + if (!priceFeedDrivers[collateralBrandKey]) { + priceFeedDrivers[collateralBrandKey] = await makePriceFeedDriver( + collateralBrandKey, + agoricNamesRemotes, + walletFactoryDriver, + // TODO read from the config file + [ + 'agoric1krunjcqfrf7la48zrvdfeeqtls5r00ep68mzkr', + 'agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8', + 'agoric144rrhh4m09mh7aaffhm6xy223ym76gve2x7y78', + 'agoric19d6gnr9fyp6hev4tlrg87zjrzsd5gzr5qlfq2p', + 'agoric1n4fcxsnkxe4gj6e24naec99hzmc4pjfdccy5nj', + ], + ); + } + + // price feed logic treats zero time as "unset" so advance to nonzero + await advanceTimeBy(1, 'seconds'); + + await priceFeedDrivers[collateralBrandKey].setPrice(price); + + // raise the VaultFactory DebtLimit + await governanceDriver.changeParams( + agoricNamesRemotes.instance.VaultFactory, + { + DebtLimit: { + brand: agoricNamesRemotes.brand.IST, + value: DebtLimitValue, + }, + }, + { + paramPath: { + key: { + collateralBrand: agoricNamesRemotes.brand[collateralBrandKey], + }, + }, + }, + ); + + // raise the PSM MintLimit + await governanceDriver.changeParams( + agoricNamesRemotes.instance['psm-IST-USDC_axl'], + { + MintLimit: { + brand: agoricNamesRemotes.brand.IST, + value: DebtLimitValue, // reuse + }, + }, + ); + + // confirm Relevant Governance Parameter Assumptions + t.like(readLatest(`${managerPath}.governance`), { + current: { + DebtLimit: { value: { value: DebtLimitValue } }, + InterestRate: { + type: 'ratio', + value: { numerator: { value: 1n }, denominator: { value: 100n } }, + }, + LiquidationMargin: { + type: 'ratio', + value: { numerator: { value: 150n }, denominator: { value: 100n } }, + }, + LiquidationPadding: { + type: 'ratio', + value: { numerator: { value: 25n }, denominator: { value: 100n } }, + }, + LiquidationPenalty: { + type: 'ratio', + value: { numerator: { value: 1n }, denominator: { value: 100n } }, + }, + MintFee: { + type: 'ratio', + value: { numerator: { value: 50n }, denominator: { value: 10_000n } }, + }, + }, + }); + t.like(readLatest('published.auction.governance'), { + current: { + AuctionStartDelay: { type: 'relativeTime', value: { relValue: 2n } }, + ClockStep: { + type: 'relativeTime', + value: { relValue: 3n * SECONDS_PER_MINUTE }, + }, + DiscountStep: { type: 'nat', value: 500n }, // 5% + LowestRate: { type: 'nat', value: 6500n }, // 65% + PriceLockPeriod: { + type: 'relativeTime', + value: { relValue: SECONDS_PER_HOUR / 2n }, + }, + StartFrequency: { + type: 'relativeTime', + value: { relValue: SECONDS_PER_HOUR }, + }, + StartingRate: { type: 'nat', value: 10500n }, // 105% + }, + }); + }; + + const check = { + vaultNotification( + managerIndex: number, + vaultIndex: number, + partial: Record, + ) { + const { readLatest } = swingsetTestKit; + + const notification = readLatest( + `published.vaultFactory.managers.manager${managerIndex}.vaults.vault${vaultIndex}`, + ); + t.like(notification, partial); + }, + }; + + const setupVaults = async ( + collateralBrandKey: string, + managerIndex: number, + setup: LiquidationSetup, + base: number = 0, + ) => { + await setupStartingState({ + collateralBrandKey, + managerIndex, + price: setup.price.starting, + }); + + const minter = + await walletFactoryDriver.provideSmartWallet('agoric1minter'); + + for (let i = 0; i < setup.vaults.length; i += 1) { + const offerId = `open-${collateralBrandKey}-vault${base + i}`; + await minter.executeOfferMaker(Offers.vaults.OpenVault, { + offerId, + collateralBrandKey, + wantMinted: setup.vaults[i].ist, + giveCollateral: setup.vaults[i].atom, + }); + t.like(minter.getLatestUpdateRecord(), { + updated: 'offerStatus', + status: { id: offerId, numWantsSatisfied: 1 }, + }); + } + + // Verify starting balances + for (let i = 0; i < setup.vaults.length; i += 1) { + check.vaultNotification(managerIndex, i, { + debtSnapshot: { + debt: { value: scale6(setup.vaults[i].debt) }, + }, + locked: { value: scale6(setup.vaults[i].atom) }, + vaultState: 'active', + }); + } + }; + + const placeBids = async ( + collateralBrandKey: string, + buyerWalletAddress: string, + setup: LiquidationSetup, + base = 0, // number of bids made before + ) => { + const buyer = + await walletFactoryDriver.provideSmartWallet(buyerWalletAddress); + + await buyer.sendOffer( + Offers.psm.swap( + agoricNamesRemotes, + agoricNamesRemotes.instance['psm-IST-USDC_axl'], + { + offerId: `print-${collateralBrandKey}-ist`, + wantMinted: 1_000, + pair: ['IST', 'USDC_axl'], + }, + ), + ); + + const maxBuy = `10000${collateralBrandKey}`; + + for (let i = 0; i < setup.bids.length; i += 1) { + const offerId = `${collateralBrandKey}-bid${i + 1 + base}`; + // bids are long-lasting offers so we can't wait here for completion + await buyer.sendOfferMaker(Offers.auction.Bid, { + offerId, + ...setup.bids[i], + maxBuy, + }); + t.like( + swingsetTestKit.readLatest(`published.wallet.${buyerWalletAddress}`), + { + status: { + id: offerId, + result: 'Your bid has been accepted', + payouts: undefined, + }, + }, + ); + } + }; + + return { + check, + priceFeedDrivers, + setupVaults, + placeBids, + setupStartingState, + }; +}; + +export const makeLiquidationTestContext = async t => { + const swingsetTestKit = await makeSwingsetTestKit(t.log, 'bundles/vaults'); + console.time('DefaultTestContext'); + + const { runUtils, storage } = swingsetTestKit; + console.timeLog('DefaultTestContext', 'swingsetTestKit'); + const { EV } = runUtils; + + // Wait for ATOM to make it into agoricNames + await EV.vat('bootstrap').consumeItem('vaultFactoryKit'); + console.timeLog('DefaultTestContext', 'vaultFactoryKit'); + + // has to be late enough for agoricNames data to have been published + const agoricNamesRemotes: AgoricNamesRemotes = + makeAgoricNamesRemotesFromFakeStorage(storage); + const refreshAgoricNamesRemotes = () => { + Object.assign( + agoricNamesRemotes, + makeAgoricNamesRemotesFromFakeStorage(storage), + ); + }; + agoricNamesRemotes.brand.ATOM || Fail`ATOM missing from agoricNames`; + console.timeLog('DefaultTestContext', 'agoricNamesRemotes'); + + const walletFactoryDriver = await makeWalletFactoryDriver( + runUtils, + storage, + agoricNamesRemotes, + ); + console.timeLog('DefaultTestContext', 'walletFactoryDriver'); + + const governanceDriver = await makeGovernanceDriver( + swingsetTestKit, + agoricNamesRemotes, + walletFactoryDriver, + // TODO read from the config file + [ + 'agoric1ldmtatp24qlllgxmrsjzcpe20fvlkp448zcuce', + 'agoric140dmkrz2e42ergjj7gyvejhzmjzurvqeq82ang', + 'agoric1w8wktaur4zf8qmmtn3n7x3r0jhsjkjntcm3u6h', + ], + ); + console.timeLog('DefaultTestContext', 'governanceDriver'); + + const liquidationTestKit = await makeLiquidationTestKit({ + swingsetTestKit, + agoricNamesRemotes, + walletFactoryDriver, + governanceDriver, + t, + }); + return { + ...swingsetTestKit, + ...liquidationTestKit, + agoricNamesRemotes, + refreshAgoricNamesRemotes, + walletFactoryDriver, + governanceDriver, + }; +}; + +export type LiquidationTestContext = Awaited< + ReturnType +>; + +const addSTARsCollateral = async ( + t: ExecutionContext, +) => { + const { controller, buildProposal } = t.context; + + t.log('building proposal'); + const proposal = await buildProposal( + '@agoric/builders/scripts/inter-protocol/add-STARS.js', + ); + + for await (const bundle of proposal.bundles) { + await controller.validateAndInstallBundle(bundle); + } + t.log('installed', proposal.bundles.length, 'bundles'); + + t.log('launching proposal'); + const bridgeMessage = { + type: 'CORE_EVAL', + evals: proposal.evals, + }; + t.log({ bridgeMessage }); + + const { EV } = t.context.runUtils; + /** @type {ERef} */ + const coreEvalBridgeHandler = await EV.vat('bootstrap').consumeItem( + 'coreEvalBridgeHandler', + ); + await EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); + + t.context.refreshAgoricNamesRemotes(); + + t.log('add-STARS proposal executed'); +}; + +export const ensureVaultCollateral = async ( + collateralBrandKey: string, + t: ExecutionContext, +) => { + // TODO: we'd like to have this work on any brand + const SUPPORTED_BRANDS = ['ATOM', 'STARS']; + + if (!SUPPORTED_BRANDS.includes(collateralBrandKey)) { + throw Error('Unsupported brand type'); + } + + if (collateralBrandKey === 'ATOM') { + return; + } + + if (collateralBrandKey === 'STARS') { + // eslint-disable-next-line @jessie.js/safe-await-separator + await addSTARsCollateral(t); + } +}; diff --git a/packages/inter-protocol/src/vaultFactory/liquidation.js b/packages/inter-protocol/src/vaultFactory/liquidation.js index b2efde016c0..7b3c9205fc0 100644 --- a/packages/inter-protocol/src/vaultFactory/liquidation.js +++ b/packages/inter-protocol/src/vaultFactory/liquidation.js @@ -18,6 +18,20 @@ const trace = makeTracer('LIQ'); /** @typedef {import('@agoric/time/src/types').CancelToken} CancelToken */ /** @typedef {import('@agoric/time/src/types').RelativeTimeRecord} RelativeTimeRecord */ +/** + * @typedef {MapStore< + * Vault, + * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * >} VaultData + */ + +/** + * @typedef {MapStore< + * Vault, + * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * >} VaultData + */ + const makeCancelToken = makeCancelTokenMaker('liq'); /** @@ -261,7 +275,7 @@ export const getLiquidatableVaults = ( const vaultsToLiquidate = prioritizedVaults.removeVaultsBelow( collateralizationDetails, ); - /** @type {MapStore, debtAmount: Amount<'nat'>}>} */ + /** @type {VaultData} */ const vaultData = makeScalarMapStore(); const { zcfSeat: liqSeat } = zcf.makeEmptySeatKit(); diff --git a/packages/inter-protocol/src/vaultFactory/types.js b/packages/inter-protocol/src/vaultFactory/types.js index f7f9ae5e408..eef37512d8e 100644 --- a/packages/inter-protocol/src/vaultFactory/types.js +++ b/packages/inter-protocol/src/vaultFactory/types.js @@ -11,8 +11,11 @@ * @typedef {import('../auction/auctioneer.js').AuctioneerPublicFacet} AuctioneerPublicFacet * @typedef {import('./vaultFactory.js').VaultFactoryContract['publicFacet']} VaultFactoryPublicFacet * - * @typedef {import('@agoric/time/src/types').Timestamp} Timestamp - * @typedef {import('@agoric/time/src/types').RelativeTime} RelativeTime + * @typedef {import('@agoric/time').Timestamp} Timestamp + * + * @typedef {import('@agoric/time').TimestampRecord} TimestampRecord + * + * @typedef {import('@agoric/time').RelativeTime} RelativeTime */ /** @@ -132,3 +135,26 @@ */ /** @typedef {{key: 'governedParams' | {collateralBrand: Brand}}} VaultFactoryParamPath */ + +/** + * @typedef {{ + * plan: import('./proceeds.js').DistributionPlan; + * vaultsInPlan: Array; + * }} PostAuctionParams + * + * @typedef {{ + * plan: import('./proceeds.js').DistributionPlan; + * totalCollateral: Amount<'nat'>; + * totalDebt: Amount<'nat'>; + * auctionSchedule: import('../auction/scheduler.js').FullSchedule; + * }} AuctionResultsParams + */ + +/** + * @typedef {import('./liquidation.js').VaultData} VaultData + * + * @typedef {object} LiquidationVisibilityWriters + * @property {(vaultData: VaultData) => Promise} writePreAuction + * @property {(postAuctionParams: PostAuctionParams) => Promise} writePostAuction + * @property {(auctionResultParams: AuctionResultsParams) => Promise} writeAuctionResults + */ diff --git a/packages/inter-protocol/src/vaultFactory/vault.js b/packages/inter-protocol/src/vaultFactory/vault.js index 7b0ae4b4fb2..7b2904a1c3f 100644 --- a/packages/inter-protocol/src/vaultFactory/vault.js +++ b/packages/inter-protocol/src/vaultFactory/vault.js @@ -116,6 +116,9 @@ export const VaultI = M.interface('Vault', { getCurrentDebt: M.call().returns(AmountShape), getNormalizedDebt: M.call().returns(AmountShape), getVaultSeat: M.call().returns(SeatShape), + getVaultState: M.call().returns( + harden({ idInManager: M.string(), phase: M.string() }), + ), initVaultKit: M.call(SeatShape, StorageNodeShape).returns(M.promise()), liquidated: M.call().returns(undefined), liquidating: M.call().returns(undefined), @@ -592,6 +595,13 @@ export const prepareVault = (baggage, makeRecorderKit, zcf) => { return this.state.vaultSeat; }, + getVaultState() { + return { + idInManager: this.state.idInManager, + phase: this.state.phase, + }; + }, + /** * @param {ZCFSeat} seat * @param {StorageNode} storageNode diff --git a/packages/inter-protocol/src/vaultFactory/vaultDirector.js b/packages/inter-protocol/src/vaultFactory/vaultDirector.js index 86aff7c1b11..35d7bf04dd0 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultDirector.js +++ b/packages/inter-protocol/src/vaultFactory/vaultDirector.js @@ -424,7 +424,8 @@ const prepareVaultDirector = ( makeLiquidationWaker() { return makeWaker('liquidationWaker', _timestamp => { - allManagersDo(vm => vm.liquidateVaults(auctioneer)); + // XXX floating promise + allManagersDo(vm => vm.liquidateVaults(auctioneer, _timestamp)); }); }, makeReschedulerWaker() { diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index ea4b5bb45bd..2f8bc8a4f37 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -24,7 +24,7 @@ import { NotifierShape, RatioShape, } from '@agoric/ertp'; -import { makeTracer } from '@agoric/internal'; +import { allValuesSettled, makeTracer } from '@agoric/internal'; import { makeStoredNotifier, observeNotifier } from '@agoric/notifier'; import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; import { @@ -49,7 +49,8 @@ import { TopicsRecordShape, } from '@agoric/zoe/src/contractSupport/index.js'; import { PriceQuoteShape, SeatShape } from '@agoric/zoe/src/typeGuards.js'; -import { E } from '@endo/eventual-send'; +import { E, Far } from '@endo/far'; +import { TimestampShape } from '@agoric/time'; import { AuctionPFShape } from '../auction/auctioneer.js'; import { checkDebtLimit, @@ -128,6 +129,7 @@ const trace = makeTracer('VM'); * @typedef {{ * assetTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, * debtBrand: Brand<'nat'>, + * liquidationsStorageNode: StorageNode * liquidatingVaults: SetStore, * metricsTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, * poolIncrementSeat: ZCFSeat, @@ -162,6 +164,35 @@ const trace = makeTracer('VM'); * storedCollateralQuote: PriceQuote, * }} */ + +/** + * @typedef {( + * | string + * | { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } + * )[][]} PreAuctionState + * + * @typedef {(string | { phase: string })[][]} PostAuctionState + * + * @typedef {{ + * collateralOffered?: Amount<'nat'>; + * istTarget?: Amount<'nat'>; + * collateralForReserve?: Amount<'nat'>; + * shortfallToReserve?: Amount<'nat'>; + * mintedProceeds?: Amount<'nat'>; + * collateralSold?: Amount<'nat'>; + * collateralRemaining?: Amount<'nat'>; + * endTime?: import('@agoric/time').TimestampRecord | null; + * }} AuctionResultState + * + * @typedef {{ + * preAuctionRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; + * postAuctionRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; + * auctionResultRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; + * }} LiquidationRecorderKits + */ + +/** @typedef {import('./liquidation.js').VaultData} VaultData */ + // any b/c will be filled after start() const collateralEphemera = makeEphemeraProvider(() => /** @type {any} */ ({})); @@ -184,7 +215,10 @@ export const prepareVaultManagerKit = ( const makeVault = prepareVault(baggage, makeRecorderKit, zcf); /** - * @param {HeldParams & { metricsStorageNode: StorageNode }} params + * @param {HeldParams & { + * metricsStorageNode: StorageNode; + * liquidationsStorageNode: StorageNode; + * }} params * @returns {HeldParams & ImmutableState & MutableState} */ const initState = params => { @@ -192,6 +226,7 @@ export const prepareVaultManagerKit = ( debtMint, collateralBrand, metricsStorageNode, + liquidationsStorageNode, startTimeStamp, storageNode, } = params; @@ -201,7 +236,7 @@ export const prepareVaultManagerKit = ( const immutable = { debtBrand, poolIncrementSeat: zcf.makeEmptySeatKit().zcfSeat, - + liquidationsStorageNode, /** * Vaults that have been sent for liquidation. When we get proceeds (or lack * thereof) back from the liquidator, we will allocate them among the vaults. @@ -292,7 +327,9 @@ export const prepareVaultManagerKit = ( getCollateralQuote: M.call().returns(PriceQuoteShape), getPublicFacet: M.call().returns(M.remotable('publicFacet')), lockOraclePrices: M.call().returns(PriceQuoteShape), - liquidateVaults: M.call(AuctionPFShape).returns(M.promise()), + liquidateVaults: M.call(AuctionPFShape, TimestampShape).returns( + M.promise(), + ), }), }, initState, @@ -597,6 +634,148 @@ export const prepareVaultManagerKit = ( return E(metricsTopicKit.recorder).write(payload); }, + /** + * @param {TimestampRecord} timestamp + * @returns {Promise} + */ + async makeLiquidationVisibilityWriters(timestamp) { + const liquidationRecorderKits = + await this.facets.helper.makeLiquidationRecorderKits(timestamp); + + /** @param {VaultData} vaultData */ + const writePreAuction = vaultData => { + /** @type PreAuctionState */ + const preAuctionState = [...vaultData.entries()].map( + ([vault, data]) => [ + `vault${vault.getVaultState().idInManager}`, + { ...data }, + ], + ); + + return E( + liquidationRecorderKits.preAuctionRecorderKit.recorder, + ).writeFinal(preAuctionState); + }; + + /** + * @param {PostAuctionParams} params + * @returns {Promise} + */ + const writePostAuction = ({ plan, vaultsInPlan }) => { + /** @type PostAuctionState */ + const postAuctionState = plan.transfersToVault.map( + ([id, transfer]) => [ + `vault${vaultsInPlan[id].getVaultState().idInManager}`, + { + ...transfer, + phase: vaultsInPlan[id].getVaultState().phase, + }, + ], + ); + return E( + liquidationRecorderKits.postAuctionRecorderKit.recorder, + ).writeFinal(postAuctionState); + }; + + /** @param {AuctionResultsParams} params */ + const writeAuctionResults = ({ + plan, + totalCollateral, + totalDebt, + auctionSchedule, + }) => { + /** @type AuctionResultState */ + const auctionResultState = { + collateralOffered: totalCollateral, + istTarget: totalDebt, + collateralForReserve: plan.collateralForReserve, + shortfallToReserve: plan.shortfallToReserve, + mintedProceeds: plan.mintedProceeds, + collateralSold: plan.collateralSold, + collateralRemaining: plan.collatRemaining, + // @ts-expect-error + // eslint-disable-next-line @endo/no-optional-chaining + endTime: auctionSchedule?.liveAuctionSchedule.endTime, + }; + return E( + liquidationRecorderKits.auctionResultRecorderKit.recorder, + ).writeFinal(auctionResultState); + }; + + return Far('Liquidation Visibility Writers', { + writePreAuction, + writePostAuction, + writeAuctionResults, + }); + }, + + /** + * This method checks if liquidationVisibilityWriters is undefined or + * not in case of a rejected promise when creating the writers. If + * liquidationVisibilityWriters is undefined it silently notifies the + * console. Otherwise, it goes on with the writing. + * + * @param {LiquidationVisibilityWriters} liquidationVisibilityWriters + * @param {[string, object][]} writes + */ + async writeLiqVisibility(liquidationVisibilityWriters, writes) { + console.log('WRITES', writes); + if (!liquidationVisibilityWriters) { + trace( + 'writeLiqVisibility', + `Error: liquidationVisibilityWriters is ${liquidationVisibilityWriters}`, + ); + return; + } + + for (const [methodName, params] of writes) { + trace('DEBUG', methodName, params); + void liquidationVisibilityWriters[methodName](params); + } + }, + + /** + * @param {TimestampRecord} timestamp + * @returns {Promise} + */ + async makeLiquidationRecorderKits(timestamp) { + const { + state: { liquidationsStorageNode }, + } = this; + + const timestampStorageNode = E(liquidationsStorageNode).makeChildNode( + `${timestamp.absValue}`, + ); + + const [ + preAuctionStorageNode, + postAuctionStorageNode, + auctionResultStorageNode, + ] = await Promise.all([ + E(E(timestampStorageNode).makeChildNode('vaults')).makeChildNode( + 'preAuction', + ), + E(E(timestampStorageNode).makeChildNode('vaults')).makeChildNode( + 'postAuction', + ), + E(timestampStorageNode).makeChildNode('auctionResult'), + ]); + + const preAuctionRecorderKit = makeRecorderKit(preAuctionStorageNode); + const postAuctionRecorderKit = makeRecorderKit( + postAuctionStorageNode, + ); + const auctionResultRecorderKit = makeRecorderKit( + auctionResultStorageNode, + ); + + return { + preAuctionRecorderKit, + postAuctionRecorderKit, + auctionResultRecorderKit, + }; + }, + /** * This is designed to tolerate an incomplete plan, in case calculateDistributionPlan encounters * an error during its calculation. We don't have a way to induce such errors in CI so we've @@ -1047,9 +1226,10 @@ export const prepareVaultManagerKit = ( return storedCollateralQuote; }, /** - * @param {AuctioneerPublicFacet} auctionPF + * @param {ERef} auctionPF + * @param {TimestampRecord} timestamp */ - async liquidateVaults(auctionPF) { + async liquidateVaults(auctionPF, timestamp) { const { state, facets } = this; const { self, helper } = facets; const { @@ -1094,11 +1274,12 @@ export const prepareVaultManagerKit = ( liquidatingVaults.getSize(), totalCollateral, ); + const schedulesP = E(auctionPF).getSchedules(); helper.markLiquidating(totalDebt, totalCollateral); void helper.writeMetrics(); - const { userSeatPromise, deposited } = await E.when( + const makeDeposit = E.when( E(auctionPF).makeDepositInvitation(), depositInvitation => offerTo( @@ -1112,6 +1293,26 @@ export const prepareVaultManagerKit = ( ), ); + // helper.makeLiquidationVisibilityWriters and schedulesP depends on others vats, + // so we switched from Promise.all to Promise.allSettled because if one of those vats fail + // we don't want those failures to prevent liquidation process from going forward. + // We don't handle the case where 'makeDeposit' rejects as liquidation depends on + // 'makeDeposit' being fulfilled. + const { + makeDeposit: { userSeatPromise, deposited }, + liquidationVisibilityWriters, + auctionSchedule, + } = await allValuesSettled({ + makeDeposit, + liquidationVisibilityWriters: + helper.makeLiquidationVisibilityWriters(timestamp), + auctionSchedule: schedulesP, + }); + + void helper.writeLiqVisibility(liquidationVisibilityWriters, [ + ['writePreAuction', vaultData], + ]); + // This is expected to wait for the duration of the auction, which // is controlled by the auction parameters startFrequency, clockStep, // and the difference between startingRate and lowestRate. @@ -1122,14 +1323,16 @@ export const prepareVaultManagerKit = ( ); trace(`LiqV after long wait`, proceeds); + let plan; + let vaultsInPlan; try { - const { plan, vaultsInPlan } = helper.planProceedsDistribution( + ({ plan, vaultsInPlan } = helper.planProceedsDistribution( proceeds, totalDebt, storedCollateralQuote, vaultData, totalCollateral, - ); + )); trace('PLAN', plan); // distributeProceeds may reconstitute vaults, removing them from liquidatingVaults helper.distributeProceeds({ @@ -1149,8 +1352,28 @@ export const prepareVaultManagerKit = ( vault.liquidated(); liquidatingVaults.delete(vault); } - - await facets.helper.writeMetrics(); + void helper.writeLiqVisibility( + liquidationVisibilityWriters, + harden([ + [ + 'writeAuctionResults', + { + plan, + totalCollateral, + totalDebt, + auctionSchedule, + }, + ], + [ + 'writePostAuction', + { + plan, + vaultsInPlan, + }, + ], + ]), + ); + void helper.writeMetrics(); }, }, }, @@ -1174,14 +1397,22 @@ export const prepareVaultManagerKit = ( }, ); - /** @param {Omit[0], 'metricsStorageNode'>} externalParams */ + /** + * @param {Omit< + * Parameters[0], + * 'metricsStorageNode' | 'liquidationsStorageNode' + * >} externalParams + */ const makeVaultManagerKit = async externalParams => { - const metricsStorageNode = await E( - externalParams.storageNode, - ).makeChildNode('metrics'); + const [metricsStorageNode, liquidationsStorageNode] = await Promise.all([ + E(externalParams.storageNode).makeChildNode('metrics'), + E(externalParams.storageNode).makeChildNode('liquidations'), + ]); + return makeVaultManagerKitInternal({ ...externalParams, metricsStorageNode, + liquidationsStorageNode, }); }; return makeVaultManagerKit; diff --git a/packages/inter-protocol/test/liquidationVisibility/assertions.js b/packages/inter-protocol/test/liquidationVisibility/assertions.js new file mode 100644 index 00000000000..9fe9ef08429 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/assertions.js @@ -0,0 +1,268 @@ +import '@agoric/zoe/exported.js'; +import { E } from '@endo/eventual-send'; +import { assertPayoutAmount } from '@agoric/zoe/test/zoeTestHelpers.js'; +import { AmountMath } from '@agoric/ertp'; +import { + ceilMultiplyBy, + makeRatio, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { TimeMath } from '@agoric/time'; +import { headValue } from '../supports.js'; +import { getDataFromVstorage } from './tools.js'; + +export const assertBidderPayout = async ( + t, + bidderSeat, + run, + curr, + aeth, + coll, +) => { + const bidderResult = await E(bidderSeat).getOfferResult(); + t.is(bidderResult, 'Your bid has been accepted'); + const payouts = await E(bidderSeat).getPayouts(); + const { Collateral: bidderCollateral, Bid: bidderBid } = payouts; + (!bidderBid && curr === 0n) || + (await assertPayoutAmount(t, run.issuer, bidderBid, run.make(curr))); + (!bidderCollateral && coll === 0n) || + (await assertPayoutAmount( + t, + aeth.issuer, + bidderCollateral, + aeth.make(coll), + 'amount ', + )); +}; + +export const assertReserveState = async (metricTracker, method, expected) => { + switch (method) { + case 'initial': + await metricTracker.assertInitial(expected); + break; + case 'like': + await metricTracker.assertLike(expected); + break; + case 'state': + await metricTracker.assertState(expected); + break; + default: + console.log('Default'); + break; + } +}; + +export const assertVaultCurrentDebt = async (t, vault, debt) => { + const debtAmount = await E(vault).getCurrentDebt(); + + if (debt === 0n) { + t.deepEqual(debtAmount.value, debt); + return; + } + + const fee = ceilMultiplyBy(debt, t.context.rates.mintFee); + + t.deepEqual( + debtAmount, + AmountMath.add(debt, fee), + 'borrower Minted amount does not match Vault current debt', + ); +}; + +export const assertVaultCollateral = async ( + t, + vault, + collateralValue, + asset, +) => { + const collateralAmount = await E(vault).getCollateralAmount(); + + t.deepEqual(collateralAmount, asset.make(collateralValue)); +}; + +export const assertMintedAmount = async (t, vaultSeat, wantMinted) => { + const { Minted } = await E(vaultSeat).getFinalAllocation(); + + t.truthy(AmountMath.isEqual(Minted, wantMinted)); +}; + +export const assertMintedProceeds = async (t, vaultSeat, wantMinted) => { + const { Minted } = await E(vaultSeat).getFinalAllocation(); + const { Minted: proceedsMinted } = await E(vaultSeat).getPayouts(); + + t.truthy(AmountMath.isEqual(Minted, wantMinted)); + + t.truthy( + AmountMath.isEqual( + await E(t.context.run.issuer).getAmountOf(proceedsMinted), + wantMinted, + ), + ); +}; + +export const assertVaultLocked = async ( + t, + vaultNotifier, + lockedValue, + asset, +) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const lockedAmount = notification.value.locked; + + t.deepEqual(lockedAmount, asset.make(lockedValue)); +}; + +export const assertVaultDebtSnapshot = async (t, vaultNotifier, wantMinted) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const debtSnapshot = notification.value.debtSnapshot; + const fee = ceilMultiplyBy(wantMinted, t.context.rates.mintFee); + + t.deepEqual(debtSnapshot, { + debt: AmountMath.add(wantMinted, fee), + interest: makeRatio(100n, t.context.run.brand), + }); + + return notification; +}; + +export const assertVaultState = async (t, vaultNotifier, phase) => { + const notification = await E(vaultNotifier).getUpdateSince(); + const vaultState = notification.value.vaultState; + + t.is(vaultState, phase); + + return notification; +}; + +export const assertVaultSeatExited = async (t, vaultSeat) => { + t.truthy(await E(vaultSeat).hasExited()); +}; + +export const assertVaultFactoryRewardAllocation = async ( + t, + vaultFactory, + rewardValue, +) => { + const rewardAllocation = await E(vaultFactory).getRewardAllocation(); + + t.deepEqual(rewardAllocation, { + Minted: t.context.run.make(rewardValue), + }); +}; + +export const assertCollateralProceeds = async (t, seat, colWanted, issuer) => { + const { Collateral: withdrawnCol } = await E(seat).getFinalAllocation(); + const proceeds4 = await E(seat).getPayouts(); + t.deepEqual(withdrawnCol, colWanted); + + const collateralWithdrawn = await proceeds4.Collateral; + t.truthy( + AmountMath.isEqual( + await E(issuer).getAmountOf(collateralWithdrawn), + colWanted, + ), + ); +}; + +// Update these assertions to use a tracker similar to test-auctionContract +export const assertBookData = async ( + t, + auctioneerBookDataSubscriber, + expectedBookData, +) => { + const auctioneerBookData = await E( + auctioneerBookDataSubscriber, + ).getUpdateSince(); + + t.deepEqual(auctioneerBookData.value, expectedBookData); +}; + +export const assertAuctioneerSchedule = async ( + t, + auctioneerPublicTopics, + expectedSchedule, +) => { + const auctioneerSchedule = await E( + auctioneerPublicTopics.schedule.subscriber, + ).getUpdateSince(); + + t.deepEqual(auctioneerSchedule.value, expectedSchedule); +}; + +export const assertAuctioneerPathData = async ( + t, + hasTopics, + brand, + topicName, + path, + dataKeys, +) => { + let topic; + if (brand) { + topic = await E(hasTopics) + .getPublicTopics(brand) + .then(topics => topics[topicName]); + } else { + topic = await E(hasTopics) + .getPublicTopics() + .then(topics => topics[topicName]); + } + + t.is(await topic?.storagePath, path, 'topic storagePath must match'); + const latest = /** @type {Record} */ ( + await headValue(topic.subscriber) + ); + if (dataKeys !== undefined) { + // TODO consider making this a shape instead + t.deepEqual(Object.keys(latest), dataKeys, 'keys in topic feed must match'); + } +}; + +export const assertVaultData = async ( + t, + vaultDataSubscriber, + vaultDataVstorage, +) => { + const auctioneerBookData = await E(vaultDataSubscriber).getUpdateSince(); + t.deepEqual(auctioneerBookData.value, vaultDataVstorage[0][1]); +}; + +export const assertNodeInStorage = async ({ + t, + rootNode, + desiredNode, + expected, +}) => { + const [...storageData] = await getDataFromVstorage(rootNode, desiredNode); + t.is(storageData.length !== 0, expected); +}; + +// Currently supports only one collateral manager +export const assertLiqNodeForAuctionCreated = async ({ + t, + rootNode, + auctioneerPF, + auctionType = 'next', // 'live' is the other option + expected = false, +}) => { + const schedules = await E(auctioneerPF).getSchedules(); + const { startTime, startDelay } = schedules[`${auctionType}AuctionSchedule`]; + const nominalStart = TimeMath.subtractAbsRel(startTime, startDelay); + + await assertNodeInStorage({ + t, + rootNode, + desiredNode: `vaultFactory.managers.manager0.liquidations.${nominalStart}`, + expected, + }); +}; + +export const assertStorageData = async ({ t, path, storageRoot, expected }) => { + /** @type Array */ + const [[, value]] = await getDataFromVstorage(storageRoot, path); + t.deepEqual(value, expected); +}; + +export const assertVaultNotification = async ({ t, notifier, expected }) => { + const { value } = await E(notifier).getUpdateSince(); + t.like(value, expected); +}; diff --git a/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js b/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js new file mode 100644 index 00000000000..f7adb16557b --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js @@ -0,0 +1,752 @@ +import '@agoric/governance/exported.js'; +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; + +import { AmountMath, AmountShape, BrandShape } from '@agoric/ertp'; +import { handleParamGovernance } from '@agoric/governance'; +import { BASIS_POINTS, makeTracer } from '@agoric/internal'; +import { prepareDurablePublishKit } from '@agoric/notifier'; +import { mustMatch } from '@agoric/store'; +import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; +import { M, provideDurableMapStore } from '@agoric/vat-data'; +import { + atomicRearrange, + ceilDivideBy, + ceilMultiplyBy, + defineERecorderKit, + defineRecorderKit, + floorDivideBy, + floorMultiplyBy, + makeRatio, + makeRatioFromAmounts, + makeRecorderTopic, + natSafeMath, + prepareRecorder, + provideEmptySeat, + offerTo, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { FullProposalShape } from '@agoric/zoe/src/typeGuards.js'; +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +import { makeNatAmountShape } from '../../src/contractSupport.js'; +import { + makeOfferSpecShape, + prepareAuctionBook, +} from '../../src/auction/auctionBook.js'; +import { auctioneerParamTypes } from '../../src/auction/params.js'; +import { makeScheduler } from '../../src/auction/scheduler.js'; +import { AuctionState } from '../../src/auction/util.js'; + +/** @typedef {import('@agoric/vat-data').Baggage} Baggage */ + +const { Fail, quote: q } = assert; +const { add, multiply } = natSafeMath; + +const trace = makeTracer('Auction', true); + +/** + * @file In this file, 'Bid' is the name of the ERTP issuer used to purchase + * collateral from various issuers. It's too confusing to also use Bid as a + * verb or a description of amounts offered, so we've tried to find + * alternatives in all those cases. + */ + +const MINIMUM_BID_GIVE = 1n; + +/** + * @param {NatValue} rate + * @param {Brand<'nat'>} bidBrand + * @param {Brand<'nat'>} collateralBrand + */ +const makeBPRatio = (rate, bidBrand, collateralBrand = bidBrand) => + makeRatioFromAmounts( + AmountMath.make(bidBrand, rate), + AmountMath.make(collateralBrand, BASIS_POINTS), + ); + +/** + * The auction sold some amount of collateral, and raised a certain amount of + * Bid. The excess collateral was returned as `unsoldCollateral`. The Bid amount + * collected from the auction participants is `proceeds`. + * + * Return a set of transfers for atomicRearrange() that distribute + * `unsoldCollateral` and `proceeds` proportionally to each seat's deposited + * amount. Any uneven split should be allocated to the reserve. + * + * @param {Amount} unsoldCollateral + * @param {Amount} proceeds + * @param {{ seat: ZCFSeat; amount: Amount<'nat'>; goal: Amount<'nat'> }[]} deposits + * @param {ZCFSeat} collateralSeat + * @param {ZCFSeat} bidHoldingSeat seat with the Bid allocation to be + * distributed + * @param {string} collateralKeyword The Reserve will hold multiple collaterals, + * so they need distinct keywords + * @param {ZCFSeat} reserveSeat + * @param {Brand} brand + */ +const distributeProportionalShares = ( + unsoldCollateral, + proceeds, + deposits, + collateralSeat, + bidHoldingSeat, + collateralKeyword, + reserveSeat, + brand, +) => { + const totalCollDeposited = deposits.reduce((prev, { amount }) => { + return AmountMath.add(prev, amount); + }, AmountMath.makeEmpty(brand)); + + const collShare = makeRatioFromAmounts(unsoldCollateral, totalCollDeposited); + const currShare = makeRatioFromAmounts(proceeds, totalCollDeposited); + /** @type {TransferPart[]} */ + const transfers = []; + let proceedsLeft = proceeds; + let collateralLeft = unsoldCollateral; + + // each depositor gets a share that equals their amount deposited + // divided by the total deposited multiplied by the Bid and + // collateral being distributed. + for (const { seat, amount } of deposits.values()) { + const currPortion = floorMultiplyBy(amount, currShare); + proceedsLeft = AmountMath.subtract(proceedsLeft, currPortion); + const collPortion = floorMultiplyBy(amount, collShare); + collateralLeft = AmountMath.subtract(collateralLeft, collPortion); + transfers.push([bidHoldingSeat, seat, { Bid: currPortion }]); + transfers.push([collateralSeat, seat, { Collateral: collPortion }]); + } + + transfers.push([bidHoldingSeat, reserveSeat, { Bid: proceedsLeft }]); + + if (!AmountMath.isEmpty(collateralLeft)) { + transfers.push([ + collateralSeat, + reserveSeat, + { Collateral: collateralLeft }, + { [collateralKeyword]: collateralLeft }, + ]); + } + + return transfers; +}; + +/** + * The auction sold some amount of collateral, and raised a certain amount of + * Bid. The excess collateral was returned as `unsoldCollateral`. The Bid amount + * collected from the auction participants is `proceeds`. + * + * Return a set of transfers for atomicRearrange() that distribute + * `unsoldCollateral` and `proceeds` proportionally to each seat's deposited + * amount. Any uneven split should be allocated to the reserve. + * + * This function is exported for testability, and is not expected to be used + * outside the contract below. + * + * Some or all of the depositors may have specified a goal amount. + * + * - A if none did, return collateral and Bid prorated to deposits. + * - B if proceeds < proceedsGoal everyone gets prorated amounts of both. + * - C if proceeds matches proceedsGoal, everyone gets the Bid they asked for, + * plus enough collateral to reach the same proportional payout. If any + * depositor's goal amount exceeded their share of the total, we'll fall back + * to the first approach. + * - D if proceeds > proceedsGoal && all depositors specified a limit, all + * depositors get their goal first, then we distribute the remainder + * (collateral and Bid) to get the same proportional payout. + * - E if proceeds > proceedsGoal && some depositors didn't specify a limit, + * depositors who did will get their goal first, then we distribute the + * remainder (collateral and Bid) to get the same proportional payout. If any + * depositor's goal amount exceeded their share of the total, we'll fall back + * as above. Think of it this way: those who specified a limit want as much + * collateral back as possible, consistent with raising a certain amount of + * Bid. Those who didn't specify a limit are trying to sell collateral, and + * would prefer to have as much as possible converted to Bid. + * + * @param {Amount<'nat'>} unsoldCollateral + * @param {Amount<'nat'>} proceeds + * @param {{ seat: ZCFSeat; amount: Amount<'nat'>; goal: Amount<'nat'> }[]} deposits + * @param {ZCFSeat} collateralSeat + * @param {ZCFSeat} bidHoldingSeat seat with the Bid allocation to be + * distributed + * @param {string} collateralKeyword The Reserve will hold multiple collaterals, + * so they need distinct keywords + * @param {ZCFSeat} reserveSeat + * @param {Brand} brand + */ +export const distributeProportionalSharesWithLimits = ( + unsoldCollateral, + proceeds, + deposits, + collateralSeat, + bidHoldingSeat, + collateralKeyword, + reserveSeat, + brand, +) => { + trace('distributeProportionally with limits'); + // unmatched is the sum of the deposits by those who didn't specify a goal + const [collDeposited, proceedsGoal, unmatchedDeposits] = deposits.reduce( + (prev, { amount, goal }) => { + const nextDeposit = AmountMath.add(prev[0], amount); + const [proceedsSum, unmatchedSum] = goal + ? [AmountMath.add(goal, prev[1]), prev[2]] + : [prev[1], AmountMath.add(prev[2], amount)]; + return [nextDeposit, proceedsSum, unmatchedSum]; + }, + [ + AmountMath.makeEmpty(brand), + AmountMath.makeEmptyFromAmount(proceeds), + AmountMath.makeEmpty(brand), + ], + ); + + const distributeProportionally = () => + distributeProportionalShares( + unsoldCollateral, + proceeds, + deposits, + collateralSeat, + bidHoldingSeat, + collateralKeyword, + reserveSeat, + brand, + ); + + // cases A and B + if ( + AmountMath.isEmpty(proceedsGoal) || + !AmountMath.isGTE(proceeds, proceedsGoal) + ) { + return distributeProportionally(); + } + + // Calculate multiplier for collateral that gives total value each depositor + // should get. + // + // The average price of collateral is proceeds / CollateralSold. + // The value of Collateral is Price * unsoldCollateral. + // The overall total value to be distributed is + // Proceeds + collateralValue. + // Each depositor should get bid and collateral that sum to the overall + // total value multiplied by the ratio of that depositor's collateral + // deposited to all the collateral deposited. + // + // To improve the resolution of the result, we only divide once, so we + // multiply each depositor's collateral remaining by this expression. + // + // collSold * proceeds + proceeds * unsoldCollateral + // ----------------------------------------------------------- + // collSold * totalCollDeposit + // + // If you do the dimension analysis, we'll multiply collateral by a ratio + // representing Bid/collateral. + + // average value of collateral is collateralSold / proceeds + const collateralSold = AmountMath.subtract(collDeposited, unsoldCollateral); + const numeratorValue = add( + multiply(collateralSold.value, proceeds.value), + multiply(unsoldCollateral.value, proceeds.value), + ); + const denominatorValue = multiply(collateralSold.value, collDeposited.value); + const totalValueRatio = makeRatioFromAmounts( + AmountMath.make(proceeds.brand, numeratorValue), + AmountMath.make(brand, denominatorValue), + ); + + const avgPrice = makeRatioFromAmounts(proceeds, collateralSold); + + // Allocate the proceedsGoal amount to depositors who specified it. Add + // collateral to reach their share. Then see what's left, and allocate it + // among the remaining depositors. Escape to distributeProportionalShares if + // anything doesn't work. + /** @type {TransferPart[]} */ + const transfers = []; + let proceedsLeft = proceeds; + let collateralLeft = unsoldCollateral; + + // case C + if (AmountMath.isEqual(proceedsGoal, proceeds)) { + // each depositor gets a share that equals their amount deposited + // multiplied by totalValueRatio computed above. + + for (const { seat, amount, goal } of deposits.values()) { + const depositorValue = floorMultiplyBy(amount, totalValueRatio); + if (goal === null || AmountMath.isGTE(depositorValue, goal)) { + let valueNeeded = depositorValue; + if (goal !== null && !AmountMath.isEmpty(goal)) { + proceedsLeft = AmountMath.subtract(proceedsLeft, goal); + transfers.push([bidHoldingSeat, seat, { Bid: goal }]); + valueNeeded = AmountMath.subtract(depositorValue, goal); + } + + const collateralToAdd = floorDivideBy(valueNeeded, avgPrice); + collateralLeft = AmountMath.subtract(collateralLeft, collateralToAdd); + transfers.push([collateralSeat, seat, { Collateral: collateralToAdd }]); + } else { + // This depositor asked for more than their share. + // ignore `transfers` and distribute everything proportionally. + return distributeProportionally(); + } + } + } else { + // Cases D & E. Proceeds > proceedsGoal, so those who specified a limit + // receive at least their target. + + const collateralValue = floorMultiplyBy(unsoldCollateral, avgPrice); + const totalDistributableValue = AmountMath.add(proceeds, collateralValue); + // The share for those who specified a limit is proportional to their + // collateral. ceiling because it's a lower limit on the restrictive branch + const limitedShare = ceilMultiplyBy( + AmountMath.subtract(collDeposited, unmatchedDeposits), + makeRatioFromAmounts(totalDistributableValue, collDeposited), + ); + + // if proceedsGoal + value of unsoldCollateral >= limitedShare then those + // who specified a limit can get all the excess over their limit in + // collateral. Others share whatever is left. + // If proceedsGoal + unsoldCollateral < limitedShare then those who + // specified share all the collateral, and everyone gets Bid to cover + // the remainder of their share. + const limitedGetMaxCollateral = AmountMath.isGTE( + AmountMath.add(proceedsGoal, collateralValue), + limitedShare, + ); + + const calcNotLimitedCollateralShare = () => { + if (limitedGetMaxCollateral) { + // those who limited will get limitedShare - proceedsGoal in collateral + const ltdCollatValue = AmountMath.subtract(limitedShare, proceedsGoal); + const ltdCollatShare = ceilDivideBy(ltdCollatValue, avgPrice); + // the unlimited will get the remainder of the collateral + return AmountMath.subtract(unsoldCollateral, ltdCollatShare); + } else { + return AmountMath.makeEmpty(brand); + } + }; + const notLimitedCollateralShare = calcNotLimitedCollateralShare(); + + for (const { seat, amount, goal } of deposits.values()) { + const depositorValue = floorMultiplyBy(amount, totalValueRatio); + + const addRemainderInBid = collateralAdded => { + const collateralVal = ceilMultiplyBy(collateralAdded, avgPrice); + /** @type {Amount<'nat'>} XXX for package depth type resolution */ + const valueNeeded = AmountMath.subtract(depositorValue, collateralVal); + + proceedsLeft = AmountMath.subtract(proceedsLeft, valueNeeded); + transfers.push([bidHoldingSeat, seat, { Bid: valueNeeded }]); + }; + + if (goal === null || AmountMath.isEmpty(goal)) { + const collateralShare = floorMultiplyBy( + notLimitedCollateralShare, + makeRatioFromAmounts(amount, unmatchedDeposits), + ); + collateralLeft = AmountMath.subtract(collateralLeft, collateralShare); + addRemainderInBid(collateralShare); + transfers.push([collateralSeat, seat, { Collateral: collateralShare }]); + } else if (limitedGetMaxCollateral) { + proceedsLeft = AmountMath.subtract(proceedsLeft, goal); + transfers.push([bidHoldingSeat, seat, { Bid: goal }]); + + const valueNeeded = AmountMath.subtract(depositorValue, goal); + const collateralToAdd = floorDivideBy(valueNeeded, avgPrice); + collateralLeft = AmountMath.subtract(collateralLeft, collateralToAdd); + transfers.push([collateralSeat, seat, { Collateral: collateralToAdd }]); + } else { + // There's not enough collateral to completely cover the gap above + // the proceedsGoal amount, so each depositor gets a proportional share + // of unsoldCollateral plus enough Bid to reach their share. + const collateralShare = floorMultiplyBy( + unsoldCollateral, + makeRatioFromAmounts(amount, collDeposited), + ); + collateralLeft = AmountMath.subtract(collateralLeft, collateralShare); + addRemainderInBid(collateralShare); + transfers.push([collateralSeat, seat, { Collateral: collateralShare }]); + } + } + } + + transfers.push([bidHoldingSeat, reserveSeat, { Bid: proceedsLeft }]); + + if (!AmountMath.isEmpty(collateralLeft)) { + transfers.push([ + collateralSeat, + reserveSeat, + { Collateral: collateralLeft }, + { [collateralKeyword]: collateralLeft }, + ]); + } + return transfers; +}; + +/** + * @param {ZCF< + * GovernanceTerms & { + * timerService: import('@agoric/time').TimerService; + * reservePublicFacet: AssetReservePublicFacet; + * priceAuthority: PriceAuthority; + * } + * >} zcf + * @param {{ + * initialPoserInvitation: Invitation; + * storageNode: StorageNode; + * marshaller: Marshaller; + * }} privateArgs + * @param {Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const { brands, timerService: timer, priceAuthority } = zcf.getTerms(); + timer || Fail`Timer must be in Auctioneer terms`; + const timerBrand = await E(timer).getTimerBrand(); + + const bidAmountShape = { brand: brands.Bid, value: M.nat() }; + + /** + * @type {MapStore< + * Brand, + * import('../../src/auction/auctionBook.js').AuctionBook + * >} + */ + const books = provideDurableMapStore(baggage, 'auctionBooks'); + /** + * @type {MapStore< + * Brand, + * { seat: ZCFSeat; amount: Amount<'nat'>; goal: Amount<'nat'> }[] + * >} + */ + const deposits = provideDurableMapStore(baggage, 'deposits'); + /** @type {MapStore} */ + const brandToKeyword = provideDurableMapStore(baggage, 'brandToKeyword'); + + const reserveSeat = provideEmptySeat(zcf, baggage, 'collateral'); + + let bookCounter = 0; + + const makeDurablePublishKit = prepareDurablePublishKit( + baggage, + 'Auction publish kit', + ); + const makeRecorder = prepareRecorder(baggage, privateArgs.marshaller); + + const makeRecorderKit = defineRecorderKit({ + makeRecorder, + makeDurablePublishKit, + }); + + const makeAuctionBook = prepareAuctionBook(baggage, zcf, makeRecorderKit); + + const makeERecorderKit = defineERecorderKit({ + makeRecorder, + makeDurablePublishKit, + }); + const scheduleKit = makeERecorderKit( + E(privateArgs.storageNode).makeChildNode('schedule'), + /** + * @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher< + * import('../../src/auction/scheduler.js').ScheduleNotification + * >} + */ (M.any()), + ); + + /** + * @param {ZCFSeat} seat + * @param {Amount<'nat'>} amount + * @param {Amount<'nat'> | null} goal + */ + const addDeposit = (seat, amount, goal = null) => { + appendToStoredArray(deposits, amount.brand, harden({ seat, amount, goal })); + }; + + const sendToReserve = keyword => { + const { reservePublicFacet } = zcf.getTerms(); + + const amount = reserveSeat.getCurrentAllocation()[keyword]; + if (!amount || AmountMath.isEmpty(amount)) { + return; + } + + const invitation = E(reservePublicFacet).makeAddCollateralInvitation(); + // don't wait for a response + void E.when(invitation, invite => { + const proposal = { give: { Collateral: amount } }; + void offerTo( + zcf, + invite, + { [keyword]: 'Collateral' }, + proposal, + reserveSeat, + ); + }); + }; + + // Called "discount" rate even though it can be above or below 100%. + /** @type {NatValue} */ + let currentDiscountRateBP; + + const distributeProceeds = () => { + for (const brand of deposits.keys()) { + const book = books.get(brand); + const { collateralSeat, bidHoldingSeat } = book.getSeats(); + + const depositsForBrand = deposits.get(brand); + if (depositsForBrand.length === 1) { + // send it all to the one + const liqSeat = depositsForBrand[0].seat; + + atomicRearrange( + zcf, + harden([ + [collateralSeat, liqSeat, collateralSeat.getCurrentAllocation()], + [bidHoldingSeat, liqSeat, bidHoldingSeat.getCurrentAllocation()], + ]), + ); + liqSeat.exit(); + deposits.set(brand, []); + } else if (depositsForBrand.length > 1) { + const collProceeds = collateralSeat.getCurrentAllocation().Collateral; + const currProceeds = + bidHoldingSeat.getCurrentAllocation().Bid || + AmountMath.makeEmpty(brands.Bid); + const transfers = distributeProportionalSharesWithLimits( + collProceeds, + currProceeds, + depositsForBrand, + collateralSeat, + bidHoldingSeat, + brandToKeyword.get(brand), + reserveSeat, + brand, + ); + atomicRearrange(zcf, harden(transfers)); + + for (const { seat } of depositsForBrand) { + seat.exit(); + } + + sendToReserve(brandToKeyword.get(brand)); + deposits.set(brand, []); + } + } + }; + + const { augmentPublicFacet, makeFarGovernorFacet, params } = + await handleParamGovernance( + zcf, + privateArgs.initialPoserInvitation, + auctioneerParamTypes, + privateArgs.storageNode, + privateArgs.marshaller, + ); + + const tradeEveryBook = () => { + const offerScalingRatio = makeRatio( + currentDiscountRateBP, + brands.Bid, + BASIS_POINTS, + ); + + for (const book of books.values()) { + book.settleAtNewRate(offerScalingRatio); + } + }; + + const driver = Far('Auctioneer', { + reducePriceAndTrade: () => { + trace('reducePriceAndTrade'); + + natSafeMath.isGTE(currentDiscountRateBP, params.getDiscountStep()) || + Fail`rates must fall ${currentDiscountRateBP}`; + + currentDiscountRateBP = natSafeMath.subtract( + currentDiscountRateBP, + params.getDiscountStep(), + ); + + tradeEveryBook(); + }, + finalize: () => { + trace('finalize'); + + for (const book of books.values()) { + book.endAuction(); + } + distributeProceeds(); + }, + startRound() { + trace('startRound'); + + currentDiscountRateBP = params.getStartingRate(); + for (const book of books.values()) { + book.setStartingRate(makeBPRatio(currentDiscountRateBP, brands.Bid)); + } + + tradeEveryBook(); + }, + capturePrices() { + for (const book of books.values()) { + book.captureOraclePriceForRound(); + } + }, + }); + + // eslint-disable-next-line no-use-before-define + const isActive = () => scheduler.getAuctionState() === AuctionState.ACTIVE; + + /** + * @param {ZCFSeat} zcfSeat + * @param {{ goal: Amount<'nat'> }} offerArgs + */ + const depositOfferHandler = (zcfSeat, offerArgs) => { + const goalMatcher = M.or(undefined, { goal: bidAmountShape }); + mustMatch(offerArgs, harden(goalMatcher)); + const { Collateral: collateralAmount } = zcfSeat.getCurrentAllocation(); + const book = books.get(collateralAmount.brand); + trace(`deposited ${q(collateralAmount)} goal: ${q(offerArgs?.goal)}`); + + book.addAssets(collateralAmount, zcfSeat, offerArgs?.goal); + addDeposit(zcfSeat, collateralAmount, offerArgs?.goal); + return 'deposited'; + }; + + const makeDepositInvitation = () => + zcf.makeInvitation( + depositOfferHandler, + 'deposit Collateral', + undefined, + M.splitRecord({ give: { Collateral: AmountShape } }), + ); + + const biddingProposalShape = M.splitRecord( + { + give: { + Bid: makeNatAmountShape(brands.Bid, MINIMUM_BID_GIVE), + }, + }, + { + maxBuy: M.or({ Collateral: AmountShape }, {}), + exit: FullProposalShape.exit, + }, + ); + + let rejectGetSchedules = false; + const publicFacet = augmentPublicFacet( + harden({ + /** @param {Brand<'nat'>} collateralBrand */ + makeBidInvitation(collateralBrand) { + mustMatch(collateralBrand, BrandShape); + books.has(collateralBrand) || + Fail`No book for brand ${collateralBrand}`; + const offerSpecShape = makeOfferSpecShape(brands.Bid, collateralBrand); + /** + * @param {ZCFSeat} zcfSeat + * @param {import('../../src/auction/auctionBook.js').OfferSpec} offerSpec + */ + const newBidHandler = (zcfSeat, offerSpec) => { + // xxx consider having Zoe guard the offerArgs with a provided shape + mustMatch(offerSpec, offerSpecShape); + const auctionBook = books.get(collateralBrand); + auctionBook.addOffer(offerSpec, zcfSeat, isActive()); + return 'Your bid has been accepted'; + }; + + return zcf.makeInvitation( + newBidHandler, + 'new bidding offer', + {}, + biddingProposalShape, + ); + }, + getSchedules() { + if (rejectGetSchedules === true) { + return Promise.reject(new Error('getSchedules promise has failed')); + } else { + // eslint-disable-next-line no-use-before-define + return scheduler.getSchedule(); + } + }, + setRejectGetSchedules(flag) { + rejectGetSchedules = flag; + }, + getScheduleUpdates() { + return scheduleKit.subscriber; + }, + getBookDataUpdates(brand) { + return books.get(brand).getDataUpdates(); + }, + getPublicTopics(brand) { + if (brand) { + return books.get(brand).getPublicTopics(); + } + + return { + schedule: makeRecorderTopic('Auction schedule', scheduleKit), + }; + }, + makeDepositInvitation, + ...params, + }), + ); + + const scheduler = await E.when(scheduleKit.recorderP, scheduleRecorder => + makeScheduler( + driver, + timer, + // @ts-expect-error types are correct. How to convince TS? + params, + timerBrand, + scheduleRecorder, + publicFacet.getSubscription(), + ), + ); + + const creatorFacet = makeFarGovernorFacet( + Far('Auctioneer creatorFacet', { + /** + * @param {Issuer} issuer + * @param {Keyword} kwd + */ + async addBrand(issuer, kwd) { + zcf.assertUniqueKeyword(kwd); + !baggage.has(kwd) || + Fail`cannot add brand with keyword ${kwd}. it's in use`; + const { brand } = await zcf.saveIssuer(issuer, kwd); + + const bookId = `book${bookCounter}`; + bookCounter += 1; + const bNode = await E(privateArgs.storageNode).makeChildNode(bookId); + + const newBook = await makeAuctionBook( + brands.Bid, + brand, + priceAuthority, + bNode, + ); + + // These three store.init() calls succeed or fail atomically + deposits.init(brand, harden([])); + books.init(brand, newBook); + brandToKeyword.init(brand, kwd); + }, + /** + * @returns {Promise< + * import('../../src/auction/scheduler.js').FullSchedule + * >} + */ + getSchedule() { + return E(scheduler).getSchedule(); + }, + }), + ); + + return { publicFacet, creatorFacet }; +}; + +/** @typedef {ContractOf} AuctioneerContract */ +/** @typedef {AuctioneerContract['publicFacet']} AuctioneerPublicFacet */ +/** @typedef {AuctioneerContract['creatorFacet']} AuctioneerCreatorFacet */ + +export const AuctionPFShape = M.remotable('Auction Public Facet'); diff --git a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js new file mode 100644 index 00000000000..21fe87ea705 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js @@ -0,0 +1,568 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { E } from '@endo/eventual-send'; +import { M } from '@endo/patterns'; +import { makeIssuerKit, AssetKind } from '@agoric/ertp'; +import { makeTracer } from '@agoric/internal'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import '../../src/vaultFactory/types.js'; +import '@agoric/zoe/exported.js'; +import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data/src/index.js'; +import { providePriceAuthorityRegistry } from '@agoric/vats/src/priceAuthorityRegistry.js'; +import { makeScriptedPriceAuthority } from '@agoric/zoe/tools/scriptedPriceAuthority.js'; +import * as utils from '@agoric/vats/src/core/utils.js'; +import { makePromiseSpace, makeAgoricNamesAccess } from '@agoric/vats'; +import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; +import { produceDiagnostics } from '@agoric/vats/src/core/basic-behaviors.js'; +import { Far } from '@endo/far'; +import { unmarshalFromVstorage } from '@agoric/internal/src/marshal.js'; +import { bindAllMethods } from '@agoric/internal/src/method-tools.js'; +import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; +import { + isStreamCell, + assertPathSegment, +} from '@agoric/internal/src/lib-chainStorage.js'; +import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import * as cb from '@agoric/internal/src/callback.js'; +import { installPuppetGovernance, produceInstallations } from '../supports.js'; +import { startEconomicCommittee } from '../../src/proposals/startEconCommittee.js'; +import { + SECONDS_PER_WEEK, + setupReserve, + startAuctioneer, +} from '../../src/proposals/econ-behaviors.js'; + +let blockMakeChildNode = ''; + +export const setBlockMakeChildNode = nodeName => { + blockMakeChildNode = nodeName; + return `LOG: blockMakeChildNode set to node ${nodeName}`; +}; + +/** + * This represents a node in an IAVL tree. + * + * The active implementation is x/vstorage, an Agoric extension of the Cosmos + * SDK. + * + * Vstorage is a hierarchical externally-reachable storage structure that + * identifies children by restricted ASCII name and is associated with arbitrary + * string-valued data for each node, defaulting to the empty string. + * + * @typedef {object} StorageNode + * @property {(data: string) => Promise} setValue publishes some data + * @property {() => string} getPath the chain storage path at which the node was + * constructed + * @property {() => Promise} getStoreKey DEPRECATED use getPath + * @property {( + * subPath: string, + * options?: { sequence?: boolean }, + * ) => StorageNode} makeChildNode + */ + +const ChainStorageNodeI = M.interface('StorageNode', { + setValue: M.callWhen(M.string()).returns(), + getPath: M.call().returns(M.string()), + getStoreKey: M.callWhen().returns(M.record()), + makeChildNode: M.call(M.string()) + .optional(M.splitRecord({}, { sequence: M.boolean() }, {})) + .returns(M.or(M.remotable('StorageNode'), M.promise())), +}); + +/** + * Must match the switch in vstorage.go using `vstorageMessage` type + * + * @typedef {| 'get' + * | 'getStoreKey' + * | 'has' + * | 'children' + * | 'entries' + * | 'values' + * | 'size'} StorageGetByPathMessageMethod + * + * @typedef {'set' | 'setWithoutNotify' | 'append'} StorageUpdateEntriesMessageMethod + * + * @typedef {| StorageGetByPathMessageMethod + * | StorageUpdateEntriesMessageMethod} StorageMessageMethod + * + * @typedef {[path: string]} StorageGetByPathMessageArgs + * + * @typedef {[path: string, value?: string | null]} StorageEntry + * + * @typedef {StorageEntry[]} StorageUpdateEntriesMessageArgs + * + * @typedef {| { + * method: StorageGetByPathMessageMethod; + * args: StorageGetByPathMessageArgs; + * } + * | { + * method: StorageUpdateEntriesMessageMethod; + * args: StorageUpdateEntriesMessageArgs; + * }} StorageMessage + */ + +/** @param {import('@agoric/base-zone').Zone} zone */ +const prepareChainStorageNode = zone => { + /** + * Create a storage node for a given backing storage interface and path. + * + * @param {import('@agoric/internal/src/callback.js').Callback< + * (message: StorageMessage) => any + * >} messenger + * a callback for sending a storageMessage object to the storage + * implementation (cf. golang/cosmos/x/vstorage/vstorage.go) + * @param {string} path + * @param {object} [options] + * @param {boolean} [options.sequence] set values with `append` messages + * rather than `set` messages so the backing implementation employs a + * wrapping structure that preserves each value set within a single block. + * Child nodes default to inheriting this option from their parent. + * @returns {StorageNode} + */ + const makeChainStorageNode = zone.exoClass( + 'ChainStorageNode', + ChainStorageNodeI, + /** + * @param {import('@agoric/internal/src/callback.js').Callback< + * (message: StorageMessage) => any + * >} messenger + * @param {string} path + * @param {object} [options] + * @param {boolean} [options.sequence] + */ + (messenger, path, { sequence = false } = {}) => { + assert.typeof(path, 'string'); + assert.typeof(sequence, 'boolean'); + return harden({ path, messenger, sequence }); + }, + { + getPath() { + return this.state.path; + }, + /** + * @deprecated use getPath + * @type {() => Promise} + */ + async getStoreKey() { + const { path, messenger } = this.state; + return cb.callE(messenger, { + method: 'getStoreKey', + args: [path], + }); + }, + + makeChildNode(name, childNodeOptions = {}) { + if (blockMakeChildNode === name) { + console.log(`Log: MOCK makeChildNode REJECTED for node ${name}`); + setBlockMakeChildNode(''); + return Promise.reject(); + } + + const { sequence, path, messenger } = this.state; + assertPathSegment(name); + const mergedOptions = { sequence, ...childNodeOptions }; + return makeChainStorageNode( + messenger, + `${path}.${name}`, + mergedOptions, + ); + }, + /** @type {(value: string) => Promise} */ + async setValue(value) { + const { sequence, path, messenger } = this.state; + assert.typeof(value, 'string'); + /** @type {StorageEntry} */ + let entry; + if (!sequence && !value) { + entry = [path]; + } else { + entry = [path, value]; + } + await cb.callE(messenger, { + method: sequence ? 'append' : 'set', + args: [entry], + }); + }, + // Possible extensions: + // * getValue() + // * getChildNames() and/or makeChildNodes() + // * getName() + // * recursive delete + // * batch operations + // * local buffering (with end-of-block commit) + }, + ); + return makeChainStorageNode; +}; + +const makeHeapChainStorageNode = prepareChainStorageNode(makeHeapZone()); + +/** + * Create a heap-based root storage node for a given backing function and root + * path. + * + * @param {(message: StorageMessage) => any} handleStorageMessage a function for + * sending a storageMessage object to the storage implementation (cf. + * golang/cosmos/x/vstorage/vstorage.go) + * @param {string} rootPath + * @param {object} [rootOptions] + * @param {boolean} [rootOptions.sequence] employ a wrapping structure that + * preserves each value set within a single block, and default child nodes to + * do the same + */ +function makeChainStorageRoot( + handleStorageMessage, + rootPath, + rootOptions = {}, +) { + const messenger = cb.makeFunctionCallback(handleStorageMessage); + + // Use the heapZone directly. + const rootNode = makeHeapChainStorageNode(messenger, rootPath, rootOptions); + return rootNode; +} + +const { Fail } = assert; + +/** + * A map corresponding with a total function such that `get(key)` is assumed to + * always succeed. + * + * @template K, V + * @typedef {{ [k in Exclude, 'get'>]: Map[k] } & { + * get: (key: K) => V; + * }} TotalMap + */ + +/** + * For testing, creates a chainStorage root node over an in-memory map and + * exposes both the map and the sequence of received messages. The `sequence` + * option defaults to true. + * + * @param {string} rootPath + * @param {Parameters[2]} [rootOptions] + */ +const makeFakeStorageKit = (rootPath, rootOptions) => { + const trace = makeTracer('StorTU', false); + const resolvedOptions = { sequence: true, ...rootOptions }; + /** @type {TotalMap} */ + const data = new Map(); + /** @param {string} prefix */ + const getChildEntries = prefix => { + assert(prefix.endsWith('.')); + const childEntries = new Map(); + for (const [path, value] of data.entries()) { + if (!path.startsWith(prefix)) { + continue; + } + const [segment, ...suffix] = path.slice(prefix.length).split('.'); + if (suffix.length === 0) { + childEntries.set(segment, value); + } else if (!childEntries.has(segment)) { + childEntries.set(segment, null); + } + } + return childEntries; + }; + /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageMessage[]} */ + const messages = []; + /** @param {import('@agoric/internal/src/lib-chainStorage.js').StorageMessage} message */ + // eslint-disable-next-line consistent-return + const toStorage = message => { + messages.push(message); + switch (message.method) { + case 'getStoreKey': { + const [key] = message.args; + return { storeName: 'swingset', storeSubkey: `fake:${key}` }; + } + case 'get': { + const [key] = message.args; + return data.has(key) ? data.get(key) : null; + } + case 'children': { + const [key] = message.args; + const childEntries = getChildEntries(`${key}.`); + return [...childEntries.keys()]; + } + case 'entries': { + const [key] = message.args; + const childEntries = getChildEntries(`${key}.`); + return [...childEntries.entries()].map(entry => + entry[1] != null ? entry : [entry[0]], + ); + } + case 'set': + case 'setWithoutNotify': { + trace('toStorage set', message); + /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageEntry[]} */ + const newEntries = message.args; + for (const [key, value] of newEntries) { + if (value != null) { + data.set(key, value); + } else { + data.delete(key); + } + } + break; + } + case 'append': { + trace('toStorage append', message); + /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageEntry[]} */ + const newEntries = message.args; + for (const [key, value] of newEntries) { + value != null || Fail`attempt to append with no value`; + // In the absence of block boundaries, everything goes in a single StreamCell. + const oldVal = data.get(key); + let streamCell; + if (oldVal != null) { + try { + streamCell = JSON.parse(oldVal); + assert(isStreamCell(streamCell)); + } catch (_err) { + streamCell = undefined; + } + } + if (streamCell === undefined) { + streamCell = { + blockHeight: '0', + values: oldVal != null ? [oldVal] : [], + }; + } + streamCell.values.push(value); + data.set(key, JSON.stringify(streamCell)); + } + break; + } + case 'size': + // Intentionally incorrect because it counts non-child descendants, + // but nevertheless supports a "has children" test. + return [...data.keys()].filter(k => k.startsWith(`${message.args[0]}.`)) + .length; + default: + throw Error(`unsupported method: ${message.method}`); + } + }; + const rootNode = makeChainStorageRoot(toStorage, rootPath, resolvedOptions); + return { + rootNode, + // eslint-disable-next-line object-shorthand + data: /** @type {Map} */ (data), + messages, + toStorage, + }; +}; +harden(makeFakeStorageKit); +/** @typedef {ReturnType} FakeStorageKit */ + +const makeMockChainStorageRoot = () => { + const { rootNode, data } = makeFakeStorageKit('mockChainStorageRoot'); + return Far('mockChainStorage', { + ...bindAllMethods(rootNode), + /** + * Defaults to deserializing slot references into plain Remotable objects + * having the specified interface name (as from `Far(iface)`), but can + * accept a different marshaller for producing Remotables that e.g. embed + * the slot string in their iface name. + * + * @param {string} path + * @param {import('@agoric/internal/src/lib-chainStorage.js').Marshaller} marshaller + * @param {number} [index] + * @returns {unknown} + */ + getBody: (path, marshaller = defaultMarshaller, index = -1) => { + data.size || Fail`no data in storage`; + /** + * @type {ReturnType< + * typeof import('@endo/marshal').makeMarshal + * >['fromCapData']} + */ + const fromCapData = (...args) => + Reflect.apply(marshaller.fromCapData, marshaller, args); + return unmarshalFromVstorage(data, path, fromCapData, index); + }, + keys: () => [...data.keys()], + }); +}; +/** @typedef {ReturnType} MockChainStorageRoot */ + +/** + * @param {any} t + * @param {import('@agoric/time').TimerService} [optTimer] + */ +const setupBootstrap = async (t, optTimer) => { + const trace = makeTracer('PromiseSpace', false); + const space = /** @type {any} */ (makePromiseSpace(trace)); + const { produce, consume } = /** + * @type {import('../../src/proposals/econ-behaviors.js').EconomyBootstrapPowers & + * BootstrapPowers} + */ (space); + + await produceDiagnostics(space); + + const timer = optTimer || buildManualTimer(t.log); + produce.chainTimerService.resolve(timer); + // @ts-expect-error + produce.chainStorage.resolve(makeMockChainStorageRoot()); + produce.board.resolve(makeFakeBoard()); + + const { zoe, feeMintAccess, run } = t.context; + produce.zoe.resolve(zoe); + produce.feeMintAccess.resolve(feeMintAccess); + + const { agoricNames, agoricNamesAdmin, spaces } = + await makeAgoricNamesAccess(); + produce.agoricNames.resolve(agoricNames); + produce.agoricNamesAdmin.resolve(agoricNamesAdmin); + + const { brand, issuer } = spaces; + brand.produce.IST.resolve(run.brand); + issuer.produce.IST.resolve(run.issuer); + + return { produce, consume, modules: { utils: { ...utils } }, ...spaces }; +}; + +/** + * @typedef {Record & { + * aeth: IssuerKit & import('../supports.js').AmountUtils; + * run: IssuerKit & import('../supports.js').AmountUtils; + * bundleCache: Awaited< + * ReturnType< + * typeof import('@agoric/swingset-vat/tools/bundleTool.js').unsafeMakeBundleCache + * > + * >; + * rates: VaultManagerParamValues; + * interestTiming: InterestTiming; + * zoe: ZoeService; + * }} Context + */ + +/** + * @param {import('ava').ExecutionContext} t + * @param {IssuerKit<'nat'>} run + * @param {IssuerKit<'nat'>} aeth + * @param {NatValue[] | Ratio} priceOrList + * @param {RelativeTime} quoteInterval + * @param {Amount | undefined} unitAmountIn + * @param {Partial} actionParamArgs + * @param {| { + * btc: any; + * btcPrice: Ratio; + * btcAmountIn: any; + * } + * | undefined} extraAssetKit + */ +export const setupElectorateReserveAndAuction = async ( + t, + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + { + StartFrequency = SECONDS_PER_WEEK, + DiscountStep = 2000n, + LowestRate = 5500n, + ClockStep = 2n, + StartingRate = 10_500n, + AuctionStartDelay = 10n, + PriceLockPeriod = 3n, + }, + extraAssetKit = undefined, +) => { + const { + zoe, + electorateTerms = { committeeName: 'The Cabal', committeeSize: 1 }, + timer, + } = t.context; + + const space = await setupBootstrap(t, timer); + installPuppetGovernance(zoe, space.installation.produce); + produceInstallations(space, t.context.installation); + + await startEconomicCommittee(space, electorateTerms); + await setupReserve(space); + const quoteIssuerKit = makeIssuerKit('quote', AssetKind.SET); + + // priceAuthorityReg is the registry, which contains and multiplexes multiple + // individual priceAuthorities, including aethPriceAuthority. + // priceAuthorityAdmin supports registering more individual priceAuthorities + // with the registry. + /** @type {import('@agoric/zoe/tools/manualPriceAuthority.js').ManualPriceAuthority} */ + // @ts-expect-error scriptedPriceAuthority doesn't actually match this, but manualPriceAuthority does + const aethTestPriceAuthority = Array.isArray(priceOrList) + ? makeScriptedPriceAuthority({ + actualBrandIn: aeth.brand, + actualBrandOut: run.brand, + priceList: priceOrList, + timer, + quoteMint: quoteIssuerKit.mint, + unitAmountIn, + quoteInterval, + }) + : makeManualPriceAuthority({ + actualBrandIn: aeth.brand, + actualBrandOut: run.brand, + initialPrice: priceOrList, + timer, + quoteIssuerKit, + }); + + let abtcTestPriceAuthority; + if (extraAssetKit) { + abtcTestPriceAuthority = Array.isArray(extraAssetKit.btcPrice) + ? makeScriptedPriceAuthority({ + actualBrandIn: extraAssetKit.btc.brand, + actualBrandOut: run.brand, + priceList: extraAssetKit.btcPrice, + timer, + quoteMint: quoteIssuerKit.mint, + unitAmountIn: extraAssetKit.btcAmountIn, + quoteInterval, + }) + : makeManualPriceAuthority({ + actualBrandIn: extraAssetKit.btc.brand, + actualBrandOut: run.brand, + initialPrice: extraAssetKit.btcPrice, + timer, + quoteIssuerKit, + }); + } + + const baggage = makeScalarBigMapStore('baggage'); + const { priceAuthority: priceAuthorityReg, adminFacet: priceAuthorityAdmin } = + providePriceAuthorityRegistry(baggage); + await E(priceAuthorityAdmin).registerPriceAuthority( + aethTestPriceAuthority, + aeth.brand, + run.brand, + ); + + if (extraAssetKit && abtcTestPriceAuthority) { + await E(priceAuthorityAdmin).registerPriceAuthority( + abtcTestPriceAuthority, + extraAssetKit.btc.brand, + run.brand, + ); + } + + space.produce.priceAuthority.resolve(priceAuthorityReg); + + const auctionParams = { + StartFrequency, + ClockStep, + StartingRate, + LowestRate, + DiscountStep, + AuctionStartDelay, + PriceLockPeriod, + }; + + await startAuctioneer(space, { auctionParams }); + return { + space, + priceAuthority: priceAuthorityReg, + priceAuthorityAdmin, + aethTestPriceAuthority, + abtcTestPriceAuthority, + }; +}; diff --git a/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md new file mode 100644 index 00000000000..6e4e42e80bb --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.md @@ -0,0 +1,603 @@ +# Snapshot report for `test/liquidationVisibility/test-liquidationVisibility.js` + +The actual snapshot is saved in `test-liquidationVisibility.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## liq-flow-1 + +> Scenario 1 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +## liq-flow-1.1 + +> Scenario 1.1 Liquidation Visibility Snapshot [Aeth] +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +> Scenario 1.1 Liquidation Visibility Snapshot [Abtc] +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager1.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aBtc brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aBtc brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aBtc brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aBtc brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager1.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aBtc brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +## liq-flow-2a + +> Scenario 2 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 3185n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 2065n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + }, + ], + ], + ], + ] + +## liq-flow-2b + +> Scenario 3 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 12n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 63n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 5n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 8n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 258n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 34n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 66n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [ + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: aEth brand {}, + value: 43n, + }, + phase: 'active', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 15n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 100n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 48n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 158n, + }, + }, + ], + ], + ], + ] + +## liq-result-scenario-1 + +> Scenario 1 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 0n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 400n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 1680n, + }, + }, + ], + ], + ], + ] + +## liq-result-scenario-2 + +> Scenario 2 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 0n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 3185n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 2065n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 700n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 5250n, + }, + }, + ], + ], + ], + ] + +## liq-result-scenario-3 + +> Scenario 3 Liquidation Visibility Snapshot +> The example below illustrates the schema of the data published there. +> +> See also board marshalling conventions (_to appear_). + + [ + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', + { + collateralForReserve: { + brand: Object @Alleged: aEth brand {}, + value: 12n, + }, + collateralOffered: { + brand: Object @Alleged: aEth brand {}, + value: 63n, + }, + collateralRemaining: { + brand: Object @Alleged: aEth brand {}, + value: 5n, + }, + collateralSold: { + brand: Object @Alleged: aEth brand {}, + value: 8n, + }, + endTime: { + absValue: 3614n, + timerBrand: Object @Alleged: timerBrand {}, + }, + istTarget: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 258n, + }, + mintedProceeds: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 34n, + }, + shortfallToReserve: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 66n, + }, + }, + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', + [ + [ + 'vault1', + { + Collateral: { + brand: Object @Alleged: aEth brand {}, + value: 43n, + }, + phase: 'active', + }, + ], + ], + ], + [ + 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', + [ + [ + 'vault0', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 15n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 100n, + }, + }, + ], + [ + 'vault1', + { + collateralAmount: { + brand: Object @Alleged: aEth brand {}, + value: 48n, + }, + debtAmount: { + brand: Object @Alleged: ZDEFAULT brand {}, + value: 158n, + }, + }, + ], + ], + ], + ] diff --git a/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.snap b/packages/inter-protocol/test/liquidationVisibility/snapshots/test-liquidationVisibility.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..3d8c83a3e2a56677227643e6e2cfe27c7aeee5a1 GIT binary patch literal 1918 zcmV-^2Z8uORzVsi1U0-NWLdu&)z#^>} ztAy;`O?wM_w`F&)(3tpcY%n1hL?ZEnU?Q3T8Z;qB!30C1(WoELgqUbzh=vb}MuLiH ze0J|`clNG(OR-Qggs0u>KL457nVs3+Z2#Rq7? zhx2*3X&g6@3&zni`ghJaJft6b^&)*=UrwG;>AQW#c3uT#bb+IR$p_n@%X zY7Em2L<8#K@OoPGw)(@|y~EkKB&sQrjEo3bNn0f(G+7yqq=k$yL=?4f&=x_LWkqz5 zC}-42dskaqM93!ePwR<_K;t4?y}9cxSJT=F)MNyxkzrZYdUBN-)omK5x;0m!I)#{P zdn{jDfD68c!#AMMq4Q9S52FdKg;LO%FFy{oz46T?p>e+AVPj(>im;4QBQ>1fLkK_W zvkqmG#WY;~0%#dMPVlvGrnfhcYS4hfqzK&#){bD-TfZe7OK~dHE1!ah6#;FtLGSgy4 zBgu7&oFF8r8t2cM&NpGAD%zkRN&Ry1`a*>%*5c1kTHg{JEr$mD)Hu{o1uz)>77h%^HPe5%ef+4ce^RVR;sKl`WDYKMqj`xfSnI>w?4osG#mZGZ53cFA)>U8liW_}Fz~hNP>jKoAXtBq#RoXzCcxxFSwC`sa4;T)P z+Uzbme%C9Lsz4;BID#k@g)JFNfJk^Ez zQ-mkmh4ipQc*M%vcu|tFs-|FoR9?ebHIc$Yj+Y0GPdWnfMRw4Cq>xA`)JF&}NUF@o zWkE^uX+cp_cnsme$S35?2+0`uz%SUO$-FQ;Oax`q?U6ifN+sHin0WJP#+!^c8E-P) zWW33Elkq0wO~#woDQ~`0;?0O7aq^G!5G}Rw+JYw|%ZdONoiws6SJVGUmg7~&$dZvI zBTGh>j4UUKESG5sMwT}^Sy~5qGRQKnC9W}9=3Z%TDZbK-31xnR{wm{6_C}L&C*w}W zos_i`mb;+!)p6&>3DDn|OZr=6d0g(CK+P{*dCk>w=RQa7JmnG`J7b;ZYq;Ptygl08 zX$rsOGCbDS)#;3$p1@Rksd)!l8-@C+MNiogJ@<`+o~Ll`Gw26r^z?xRgAiXWdMe_b5|w&{Gc*^ z{^)?8VSlywd3O#!bGHUQ6Mtb+?(`SNu`)y2$@Bs&m!L3>aCR~+Fy#HT)lQ~qSX5|- zGnu{x%iHA`(oUwo!lHBJbebXUWEz2`16oa6?PR(ImaWj3Gnu{#%Ms{Q-FQ1-7{7wy z8|Y`sIGZ;93B$k8%z)K6zdY;m0as5+Z!~5KUhOittKAg*fV*JM40;c_jO}(yx*G#d zq&w!W3BFiow<-FV%bsiRFa;lX+wEQBL2lHT>mLUyKOxL8{EMS6SKi#yxG=Ax508Jw4AoiwOJFeh|rU?^(LEj zKP(5K6SVafoAoh}4KF|2tYTJUfPlYX5R^Ngt#?E1(nr}J#13I=DW3T_&>wot8 zpQW9!w3BO|c5>ZpV_4eB^_SOyBqKw%ju6Womb2Wavd=D}G2 E0MsL~=>Px# literal 0 HcmV?d00001 diff --git a/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js new file mode 100644 index 00000000000..74e04ddd5fb --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js @@ -0,0 +1,1476 @@ +// @ts-nocheck + +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { E } from '@endo/eventual-send'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { deeplyFulfilled } from '@endo/marshal'; +import { makeTracer } from '@agoric/internal'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { + makeRatio, + makeRatioFromAmounts, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { documentStorageSchema } from '@agoric/governance/tools/storageDoc.js'; +import { AmountMath } from '@agoric/ertp'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { + defaultParamValues, + legacyOfferResult, +} from '../vaultFactory/vaultFactoryUtils.js'; +import { + SECONDS_PER_HOUR as ONE_HOUR, + SECONDS_PER_DAY as ONE_DAY, + SECONDS_PER_WEEK as ONE_WEEK, +} from '../../src/proposals/econ-behaviors.js'; +import { reserveInitialState } from '../metrics.js'; +import { + bid, + setClockAndAdvanceNTimes, + setupBasics, + setupServices, + startAuctionClock, + openVault, + getMetricTrackers, + adjustVault, + closeVault, + getDataFromVstorage, +} from './tools.js'; +import { + assertBidderPayout, + assertCollateralProceeds, + assertMintedAmount, + assertReserveState, + assertVaultCollateral, + assertVaultCurrentDebt, + assertVaultDebtSnapshot, + assertVaultFactoryRewardAllocation, + assertVaultLocked, + assertVaultSeatExited, + assertVaultState, + assertMintedProceeds, + assertLiqNodeForAuctionCreated, + assertStorageData, + assertVaultNotification, +} from './assertions.js'; +import { Phase } from '../vaultFactory/driver.js'; +import { setBlockMakeChildNode } from './mock-setupChainStorage.js'; + +const trace = makeTracer('TestLiquidationVisibility', false); + +// IST is set as RUN to be able to use ../supports.js methods +test.before(async t => { + const { zoe, feeMintAccessP } = await setUpZoeForTest(); + const feeMintAccess = await feeMintAccessP; + + const contractsWrapper = { + auctioneer: './test/liquidationVisibility/auctioneer-contract-wrapper.js', + }; + + const { run, aeth, abtc, bundleCache, bundles, installation } = + await setupBasics(zoe, contractsWrapper); + + const contextPs = { + zoe, + feeMintAccess, + bundles, + installation, + electorateTerms: undefined, + interestTiming: { + chargingPeriod: 2n, + recordingPeriod: 10n, + }, + minInitialDebt: 50n, + referencedUi: undefined, + rates: defaultParamValues(run.brand), + }; + const frozenCtx = await deeplyFulfilled(harden(contextPs)); + + t.context = { + ...frozenCtx, + bundleCache, + aeth, + abtc, + run, + }; + + trace(t, 'CONTEXT'); +}); + +/* Test liquidation flow 1: + * Auction raises enough IST to cover debt */ +test('liq-flow-1', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { vaultFactory, aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + let expectedReserveState = reserveInitialState(run.makeEmpty()); + await assertReserveState(reserveTracker, 'initial', expectedReserveState); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + await assertVaultCurrentDebt(t, vault, wantMinted); + await assertVaultState(t, vaultNotifier, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifier, wantMinted); + await assertMintedAmount(t, vaultSeat, wantMinted); + await assertVaultCollateral(t, vault, 400n, aeth); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + + await assertVaultState(t, vaultNotifier, 'active'); + + const { startTime, time, endTime } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + let currentTime = time; + + // Check that {timestamp}.vaults.preAuction values are correct before auction is completed + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.not(vstorageDuringLiquidation.length, 0); + const debtDuringLiquidation = await E(vault).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount, + debtAmount: debtDuringLiquidation, + }, + ], + ], + }); + + await assertVaultState(t, vaultNotifier, 'liquidating'); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertVaultCurrentDebt(t, vault, wantMinted); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, startTime, 2n); + trace(`advanced time to `, currentTime); + + await assertVaultState(t, vaultNotifier, 'liquidated'); + await assertVaultSeatExited(t, vaultSeat); + await assertVaultLocked(t, vaultNotifier, 0n, aeth); + await assertVaultCurrentDebt(t, vault, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 80n); + + const closeSeat = await closeVault({ t, vault }); + await E(closeSeat).getOfferResult(); + + await assertCollateralProceeds(t, closeSeat, aeth.makeEmpty(), aeth.issuer); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertBidderPayout(t, bidderSeat, run, 320n, aeth, 400n); + + expectedReserveState = { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }; + await assertReserveState(reserveTracker, 'like', expectedReserveState); + + // Check that {timestamp}.vaults.postAuction values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + // Check that {timestamp}.auctionResult values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmount, + istTarget: run.make(1680n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: aeth.make(400n), + collateralRemaining: aeth.makeEmpty(), + endTime, + }, + }); + + // Create snapshot of the storage node + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); +}); + +// assert that vaultId being recorded under liquidations correspond to the correct vaultId under vaults +// test flow with more than one vaultManager +test('liq-flow-1.1', async t => { + const { zoe, run, aeth, abtc } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + true, + ); + + const { + vaultFactory: { + vaultFactory, + aethCollateralManager, + abtcCollateralManager, + }, + aethTestPriceAuthority, + abtcTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker: reserveTrackerAeth } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + let expectedReserveStateAeth = reserveInitialState(run.makeEmpty()); + await assertReserveState( + reserveTrackerAeth, + 'initial', + expectedReserveStateAeth, + ); + + const { reserveTracker: reserveTrackerAbtc } = await getMetricTrackers({ + t, + collateralManager: abtcCollateralManager, + reservePublicFacet, + }); + + let expectedReserveStateAbtc = reserveInitialState(run.makeEmpty()); + await assertReserveState( + reserveTrackerAbtc, + 'initial', + expectedReserveStateAbtc, + ); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + await E(reserveCreatorFacet).addIssuer(abtc.issuer, 'Abtc'); + + const collateralAmountAeth = aeth.make(400n); + const collateralAmountAbtc = abtc.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeatAeth = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: collateralAmountAeth, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + const vaultSeatAbtc = await openVault({ + t, + cm: abtcCollateralManager, + collateralAmount: collateralAmountAbtc, + colKeyword: 'abtc', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desiredAeth = aeth.make(400n); + const desiredAbtc = abtc.make(400n); + const bidderSeatAeth = await bid( + t, + zoe, + auctioneerKit, + aeth, + bidAmount, + desiredAeth, + ); + const bidderSeatAbtc = await bid( + t, + zoe, + auctioneerKit, + abtc, + bidAmount, + desiredAbtc, + ); + + const { + vault: vaultAeth, + publicNotifiers: { vault: vaultNotifierAeth }, + } = await legacyOfferResult(vaultSeatAeth); + const { + vault: vaultAbtc, + publicNotifiers: { vault: vaultNotifierAbtc }, + } = await legacyOfferResult(vaultSeatAbtc); + + // aeth assertions + await assertVaultCurrentDebt(t, vaultAeth, wantMinted); + await assertVaultState(t, vaultNotifierAeth, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifierAeth, wantMinted); + await assertMintedAmount(t, vaultSeatAeth, wantMinted); + await assertVaultCollateral(t, vaultAeth, 400n, aeth); + + // abtc assertions + await assertVaultCurrentDebt(t, vaultAbtc, wantMinted); + await assertVaultState(t, vaultNotifierAbtc, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifierAbtc, wantMinted); + await assertMintedAmount(t, vaultSeatAbtc, wantMinted); + await assertVaultCollateral(t, vaultAbtc, 400n, abtc); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + abtcTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, abtc.brand)); + + await assertVaultState(t, vaultNotifierAeth, 'active'); + await assertVaultState(t, vaultNotifierAbtc, 'active'); + + const { startTime, time, endTime } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + let currentTime = time; + + // Check that {timestamp}.vaults.preAuction values are correct before auction is completed + // aeth + const vstorageDuringLiquidationAeth = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.not(vstorageDuringLiquidationAeth.length, 0); + const debtDuringLiquidationAeth = await E(vaultAeth).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAeth, + debtAmount: debtDuringLiquidationAeth, + }, + ], + ], + }); + + // abtc + const vstorageDuringLiquidationAbtc = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager1.liquidations`, + ); + t.not(vstorageDuringLiquidationAbtc.length, 0); + const debtDuringLiquidationAbtc = await E(vaultAbtc).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAbtc, + debtAmount: debtDuringLiquidationAbtc, + }, + ], + ], + }); + + // aeth + await assertVaultState(t, vaultNotifierAeth, 'liquidating'); + await assertVaultCollateral(t, vaultAeth, 0n, aeth); + await assertVaultCurrentDebt(t, vaultAeth, wantMinted); + + // abtc + await assertVaultState(t, vaultNotifierAbtc, 'liquidating'); + await assertVaultCollateral(t, vaultAbtc, 0n, abtc); + await assertVaultCurrentDebt(t, vaultAbtc, wantMinted); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, startTime, 2n); + trace(`advanced time to `, currentTime); + + // aeth + await assertVaultState(t, vaultNotifierAeth, 'liquidated'); + await assertVaultSeatExited(t, vaultSeatAeth); + await assertVaultLocked(t, vaultNotifierAeth, 0n, aeth); + await assertVaultCurrentDebt(t, vaultAeth, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 160n); + + // abtc + await assertVaultState(t, vaultNotifierAbtc, 'liquidated'); + await assertVaultSeatExited(t, vaultSeatAbtc); + await assertVaultLocked(t, vaultNotifierAbtc, 0n, abtc); + await assertVaultCurrentDebt(t, vaultAbtc, 0n); + + const closeSeatAeth = await closeVault({ t, vault: vaultAeth }); + await E(closeSeatAeth).getOfferResult(); + + const closeSeatAbtc = await closeVault({ t, vault: vaultAbtc }); + await E(closeSeatAbtc).getOfferResult(); + + // aeth + await assertCollateralProceeds( + t, + closeSeatAeth, + aeth.makeEmpty(), + aeth.issuer, + ); + await assertVaultCollateral(t, vaultAeth, 0n, aeth); + await assertBidderPayout(t, bidderSeatAeth, run, 320n, aeth, 400n); + + // abtc + await assertCollateralProceeds( + t, + closeSeatAbtc, + abtc.makeEmpty(), + abtc.issuer, + ); + await assertVaultCollateral(t, vaultAbtc, 0n, abtc); + await assertBidderPayout(t, bidderSeatAbtc, run, 320n, abtc, 400n); + + expectedReserveStateAeth = { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }; + await assertReserveState( + reserveTrackerAeth, + 'like', + expectedReserveStateAeth, + ); + + expectedReserveStateAbtc = { + allocations: { + Abtc: undefined, + Fee: undefined, + }, + }; + await assertReserveState( + reserveTrackerAbtc, + 'like', + expectedReserveStateAbtc, + ); + + // Check that {timestamp}.vaults.postAuction values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAeth, + debtAmount: debtDuringLiquidationAeth, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount: collateralAmountAbtc, + debtAmount: debtDuringLiquidationAbtc, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + // Check that {timestamp}.auctionResult values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmountAeth, + istTarget: run.make(1680n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: aeth.make(400n), + collateralRemaining: aeth.makeEmpty(), + endTime, + }, + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmountAbtc, + istTarget: run.make(1680n), + collateralForReserve: abtc.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: abtc.make(400n), + collateralRemaining: abtc.makeEmpty(), + endTime, + }, + }); + + // Create snapshot of the storage node + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1.1 Liquidation Visibility Snapshot [Aeth]', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 1.1 Liquidation Visibility Snapshot [Abtc]', + node: `vaultFactory.managers.manager1.liquidations.${time.absValue.toString()}`, + }); +}); + +/* Test liquidation flow 2a: + * Auction does not raise enough to cover IST debt; + * All collateral sold and debt is not covered. */ +test('liq-flow-2a', async t => { + const { zoe, aeth, run, rates: defaultRates } = t.context; + + // Add a vaultManager with 10000 aeth collateral at a 200 aeth/Minted rate + const rates = harden({ + ...defaultRates, + // charge 40% interest / year + interestRate: run.makeRatio(40n), + liquidationMargin: run.makeRatio(130n), + }); + t.context.rates = rates; + + // Interest is charged daily, and auctions are every week + t.context.interestTiming = { + chargingPeriod: ONE_DAY, + recordingPeriod: ONE_DAY, + }; + + const manualTimer = buildManualTimer(); + const services = await setupServices( + t, + makeRatio(100n, run.brand, 10n, aeth.brand), + aeth.make(1n), + manualTimer, + ONE_WEEK, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const { reserveTracker, collateralManagerTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + await assertReserveState( + reserveTracker, + 'initial', + reserveInitialState(run.makeEmpty()), + ); + let shortfallBalance = 0n; + + await collateralManagerTracker.assertInitial({ + // present + numActiveVaults: 0, + numLiquidatingVaults: 0, + totalCollateral: aeth.make(0n), + totalDebt: run.make(0n), + retainedCollateral: aeth.make(0n), + + // running + numLiquidationsCompleted: 0, + numLiquidationsAborted: 0, + totalOverageReceived: run.make(0n), + totalProceedsReceived: run.make(0n), + totalCollateralSold: aeth.make(0n), + liquidatingCollateral: aeth.make(0n), + liquidatingDebt: run.make(0n), + totalShortfallReceived: run.make(0n), + lockedQuote: null, + }); + + // Create a loan for Alice for 5000 Minted with 1000 aeth collateral + // ratio is 4:1 + const aliceCollateralAmount = aeth.make(1000n); + const aliceWantMinted = run.make(5000n); + /** @type {UserSeat} */ + const aliceVaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: aliceCollateralAmount, + wantMintedAmount: aliceWantMinted, + colKeyword: 'aeth', + }); + const { + vault: aliceVault, + publicNotifiers: { vault: aliceNotifier }, + } = await legacyOfferResult(aliceVaultSeat); + + await assertVaultCurrentDebt(t, aliceVault, aliceWantMinted); + await assertMintedProceeds(t, aliceVaultSeat, aliceWantMinted); + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 1, + totalCollateral: { value: 1000n }, + totalDebt: { value: 5250n }, + }); + + // reduce collateral + trace(t, 'alice reduce collateral'); + + // Alice reduce collateral by 300. That leaves her at 700 * 10 > 1.05 * 5000. + // Prices will drop from 10 to 7, she'll be liquidated: 700 * 7 < 1.05 * 5000. + const collateralDecrement = aeth.make(300n); + const aliceReduceCollateralSeat = await adjustVault({ + t, + vault: aliceVault, + proposal: { + want: { Collateral: collateralDecrement }, + }, + }); + await E(aliceReduceCollateralSeat).getOfferResult(); + + trace('alice '); + await assertCollateralProceeds( + t, + aliceReduceCollateralSeat, + aeth.make(300n), + aeth.issuer, + ); + + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + trace(t, 'alice reduce collateral'); + await collateralManagerTracker.assertChange({ + totalCollateral: { value: 700n }, + }); + + await assertLiqNodeForAuctionCreated({ + t, + rootNode: chainStorage, + auctioneerPF: auctioneerKit.publicFacet, + }); + + await E(aethTestPriceAuthority).setPrice( + makeRatio(70n, run.brand, 10n, aeth.brand), + ); + trace(t, 'changed price to 7 RUN/Aeth'); + + // A bidder places a bid + const bidAmount = run.make(3300n); + const desired = aeth.make(700n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + startTime: start1, + time: now1, + endTime, + } = await startAuctionClock(auctioneerKit, manualTimer); + + let currentTime = now1; + + await collateralManagerTracker.assertChange({ + lockedQuote: makeRatioFromAmounts( + aeth.make(1_000_000n), + run.make(7_000_000n), + ), + }); + + // expect Alice to be liquidated because her collateral is too low. + await assertVaultState(t, aliceNotifier, Phase.LIQUIDATING); + + // Check vaults.preAuction here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}.vaults.preAuction`, // now1 is the nominal start time + expected: [ + [ + 'vault0', + { + collateralAmount: aeth.make(700n), + debtAmount: await E(aliceVault).getCurrentDebt(), + }, + ], + ], + }); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, start1, 2n); + + await assertVaultState(t, aliceNotifier, Phase.LIQUIDATED); + trace(t, 'alice liquidated', currentTime); + await collateralManagerTracker.assertChange({ + numActiveVaults: 0, + numLiquidatingVaults: 1, + liquidatingCollateral: { value: 700n }, + liquidatingDebt: { value: 5250n }, + lockedQuote: null, + }); + + shortfallBalance += 2065n; + await reserveTracker.assertChange({ + shortfallBalance: { value: shortfallBalance }, + }); + + await collateralManagerTracker.assertChange({ + liquidatingDebt: { value: 0n }, + liquidatingCollateral: { value: 0n }, + totalCollateral: { value: 0n }, + totalDebt: { value: 0n }, + numLiquidatingVaults: 0, + numLiquidationsCompleted: 1, + totalCollateralSold: { value: 700n }, + totalProceedsReceived: { value: 3185n }, + totalShortfallReceived: { value: shortfallBalance }, + }); + + // Bidder bought 800 Aeth + await assertBidderPayout(t, bidderSeat, run, 115n, aeth, 700n); + + // Check vaults.postAuction and auctionResults here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}.vaults.postAuction`, // now1 is the nominal start time + expected: [], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}.auctionResult`, // now1 is the nominal start time + expected: { + collateralOffered: aeth.make(700n), + istTarget: run.make(5250n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.make(2065n), + mintedProceeds: run.make(3185n), + collateralSold: aeth.make(700n), + collateralRemaining: aeth.makeEmpty(), + endTime, + }, + }); + + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 2 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${now1.absValue.toString()}`, + }); +}); + +/* Test liquidation flow 2b: + * Auction does not raise enough to cover IST debt; + * Collateral remains but debt is still not covered by IST raised by auction end */ +test('liq-flow-2b', async t => { + const { zoe, aeth, run, rates: defaultRates } = t.context; + + const rates = harden({ + ...defaultRates, + interestRate: run.makeRatio(0n), + liquidationMargin: run.makeRatio(150n), + }); + t.context.rates = rates; + + const manualTimer = buildManualTimer(); + const services = await setupServices( + t, + makeRatio(1500n, run.brand, 100n, aeth.brand), + aeth.make(1n), + manualTimer, + ONE_WEEK, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { aethCollateralManager }, + auctioneerKit, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + chainStorage, + } = services; + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const { reserveTracker, collateralManagerTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + await collateralManagerTracker.assertInitial({ + // present + numActiveVaults: 0, + numLiquidatingVaults: 0, + totalCollateral: aeth.make(0n), + totalDebt: run.make(0n), + retainedCollateral: aeth.make(0n), + + // running + numLiquidationsCompleted: 0, + numLiquidationsAborted: 0, + totalOverageReceived: run.make(0n), + totalProceedsReceived: run.make(0n), + totalCollateralSold: aeth.make(0n), + liquidatingCollateral: aeth.make(0n), + liquidatingDebt: run.make(0n), + totalShortfallReceived: run.make(0n), + lockedQuote: null, + }); + + // Create a loan for Alice of 95 with 5% fee produces a debt of 100. + const aliceCollateralAmount = aeth.make(15n); + const aliceWantMinted = run.make(95n); + /** @type {UserSeat} */ + const aliceVaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: aliceCollateralAmount, + colKeyword: 'aeth', + wantMintedAmount: aliceWantMinted, + }); + const { + vault: aliceVault, + publicNotifiers: { vault: aliceNotifier }, + } = await legacyOfferResult(aliceVaultSeat); + + await assertVaultCurrentDebt(t, aliceVault, aliceWantMinted); + await assertMintedProceeds(t, aliceVaultSeat, aliceWantMinted); + + await assertVaultDebtSnapshot(t, aliceNotifier, aliceWantMinted); + await assertVaultState(t, aliceNotifier, Phase.ACTIVE); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 1, + totalDebt: { value: 100n }, + totalCollateral: { value: 15n }, + }); + + // BOB takes out a loan + const bobCollateralAmount = aeth.make(48n); + const bobWantMinted = run.make(150n); + /** @type {UserSeat} */ + const bobVaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount: bobCollateralAmount, + colKeyword: 'aeth', + wantMintedAmount: bobWantMinted, + }); + const { + vault: bobVault, + publicNotifiers: { vault: bobNotifier }, + } = await legacyOfferResult(bobVaultSeat); + + await assertVaultCurrentDebt(t, bobVault, bobWantMinted); + await assertMintedProceeds(t, bobVaultSeat, bobWantMinted); + + await assertVaultDebtSnapshot(t, bobNotifier, bobWantMinted); + await assertVaultState(t, bobNotifier, Phase.ACTIVE); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 2, + totalDebt: { value: 258n }, + totalCollateral: { value: 63n }, + }); + + // A bidder places a bid + const bidAmount = run.make(100n); + const desired = aeth.make(8n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + // price falls + await aethTestPriceAuthority.setPrice( + makeRatio(400n, run.brand, 100n, aeth.brand), + ); + await eventLoopIteration(); + + // Assert node not created + await assertLiqNodeForAuctionCreated({ + t, + rootNode: chainStorage, + auctioneerPF: auctioneerKit.publicFacet, + }); + + const { startTime, time, endTime } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + + await assertVaultState(t, aliceNotifier, Phase.LIQUIDATING); + await assertVaultState(t, bobNotifier, Phase.LIQUIDATING); + + // Check vaults.preAuction here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, // time is the nominal start time + expected: [ + [ + 'vault0', // Alice's vault + { + collateralAmount: aliceCollateralAmount, + debtAmount: await E(aliceVault).getCurrentDebt(), + }, + ], + [ + 'vault1', // Bob's vault + { + collateralAmount: bobCollateralAmount, + debtAmount: await E(bobVault).getCurrentDebt(), + }, + ], + ], + }); + + await collateralManagerTracker.assertChange({ + lockedQuote: makeRatioFromAmounts( + aeth.make(1_000_000n), + run.make(4_000_000n), + ), + }); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 0, + liquidatingDebt: { value: 258n }, + liquidatingCollateral: { value: 63n }, + numLiquidatingVaults: 2, + lockedQuote: null, + }); + + await setClockAndAdvanceNTimes(manualTimer, 2n, startTime, 2n); + + await collateralManagerTracker.assertChange({ + numActiveVaults: 1, + liquidatingDebt: { value: 0n }, + liquidatingCollateral: { value: 0n }, + totalDebt: { value: 158n }, + totalCollateral: { value: 44n }, + totalProceedsReceived: { value: 34n }, + totalShortfallReceived: { value: 66n }, + totalCollateralSold: { value: 8n }, + numLiquidatingVaults: 0, + numLiquidationsCompleted: 1, + numLiquidationsAborted: 1, + }); + + await assertVaultNotification({ + t, + notifier: aliceNotifier, + expected: { + vaultState: Phase.LIQUIDATED, + locked: aeth.makeEmpty(), + }, + }); + + // Reduce Bob's collateral by liquidation penalty + // bob's share is 7 * 158/258, which rounds up to 5 + const recoveredBobCollateral = AmountMath.subtract( + bobCollateralAmount, + aeth.make(5n), + ); + + await assertVaultNotification({ + t, + notifier: bobNotifier, + expected: { + vaultState: Phase.ACTIVE, + locked: recoveredBobCollateral, + debtSnapshot: { debt: run.make(158n) }, + }, + }); + + await assertBidderPayout(t, bidderSeat, run, 66n, aeth, 8n); + + await assertReserveState(reserveTracker, 'like', { + allocations: { + Aeth: aeth.make(12n), + Fee: undefined, + }, + }); + + // Check vaults.postAuction and auctionResults here + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, // time is the nominal start time + expected: [ + [ + 'vault1', // Bob got reinstated + { + Collateral: recoveredBobCollateral, + phase: Phase.ACTIVE, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, // now1 is the nominal start time + expected: { + collateralOffered: aeth.make(63n), + istTarget: run.make(258n), + collateralForReserve: aeth.make(12n), + shortfallToReserve: run.make(66n), + mintedProceeds: run.make(34n), + collateralSold: aeth.make(8n), + collateralRemaining: aeth.make(5n), + endTime, + }, + }); + + await documentStorageSchema(t, chainStorage, { + note: 'Scenario 3 Liquidation Visibility Snapshot', + node: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}`, + }); +}); + +/* Auction starts with no liquidatable vaults + * In this scenario, no child node of liquidation should be created */ +test('liq-no-vaults', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { aethCollateralManager }, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + const expectedReserveState = reserveInitialState(run.makeEmpty()); + await assertReserveState(reserveTracker, 'initial', expectedReserveState); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + await assertVaultCurrentDebt(t, vault, wantMinted); + await assertVaultState(t, vaultNotifier, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifier, wantMinted); + await assertMintedAmount(t, vaultSeat, wantMinted); + await assertVaultCollateral(t, vault, 400n, aeth); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // the auction will start but no vault will be liquidated + await startAuctionClock(auctioneerKit, manualTimer); + await assertVaultState(t, vaultNotifier, 'active'); + + // Check that no child node with auction start time's name created after the auction started + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageDuringLiquidation.length, 0); +}); + +/* The auctionSchedule returned schedulesP will be a rejected promise + * In this scenario, the state of auctionResult node should have endTime as undefined */ +test('liq-rejected-schedule', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { vaultFactory, aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + let expectedReserveState = reserveInitialState(run.makeEmpty()); + await assertReserveState(reserveTracker, 'initial', expectedReserveState); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + await assertVaultCurrentDebt(t, vault, wantMinted); + await assertVaultState(t, vaultNotifier, 'active'); + await assertVaultDebtSnapshot(t, vaultNotifier, wantMinted); + await assertMintedAmount(t, vaultSeat, wantMinted); + await assertVaultCollateral(t, vault, 400n, aeth); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + + await assertVaultState(t, vaultNotifier, 'active'); + + await E(auctioneerKit.publicFacet).setRejectGetSchedules(true); + + const { startTime, time } = await startAuctionClock( + auctioneerKit, + manualTimer, + ); + let currentTime = time; + + // Check that {timestamp}.vaults.preAuction values are correct before auction is completed + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.not(vstorageDuringLiquidation.length, 0); + const debtDuringLiquidation = await E(vault).getCurrentDebt(); + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount, + debtAmount: debtDuringLiquidation, + }, + ], + ], + }); + + await assertVaultState(t, vaultNotifier, 'liquidating'); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertVaultCurrentDebt(t, vault, wantMinted); + + await E(auctioneerKit.publicFacet).setRejectGetSchedules(false); + + currentTime = await setClockAndAdvanceNTimes(manualTimer, 2, startTime, 2n); + trace(`advanced time to `, currentTime); + + await assertVaultState(t, vaultNotifier, 'liquidated'); + await assertVaultSeatExited(t, vaultSeat); + await assertVaultLocked(t, vaultNotifier, 0n, aeth); + await assertVaultCurrentDebt(t, vault, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 80n); + + const closeSeat = await closeVault({ t, vault }); + await E(closeSeat).getOfferResult(); + + await assertCollateralProceeds(t, closeSeat, aeth.makeEmpty(), aeth.issuer); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertBidderPayout(t, bidderSeat, run, 320n, aeth, 400n); + + expectedReserveState = { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }; + await assertReserveState(reserveTracker, 'like', expectedReserveState); + + // Check that {timestamp}.vaults.postAuction values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.preAuction`, + expected: [ + [ + 'vault0', + { + collateralAmount, + debtAmount: debtDuringLiquidation, + }, + ], + ], + }); + + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.vaults.postAuction`, + expected: [], + }); + + // Check that {timestamp}.auctionResult values are correct after auction is completed + await assertStorageData({ + t, + storageRoot: chainStorage, + path: `vaultFactory.managers.manager0.liquidations.${time.absValue.toString()}.auctionResult`, + expected: { + collateralOffered: collateralAmount, + istTarget: run.make(1680n), + collateralForReserve: aeth.makeEmpty(), + shortfallToReserve: run.makeEmpty(), + mintedProceeds: run.make(1680n), + collateralSold: aeth.make(400n), + collateralRemaining: aeth.makeEmpty(), + endTime: undefined, + }, + }); +}); + +/* The timestampStorageNode returned makeChildNode will be a rejected promise + * In this scenario, the error should be handled and printed its message */ +test('liq-rejected-timestampStorageNode', async t => { + const { zoe, run, aeth } = t.context; + const manualTimer = buildManualTimer(); + + const services = await setupServices( + t, + makeRatio(50n, run.brand, 10n, aeth.brand), + aeth.make(400n), + manualTimer, + undefined, + { StartFrequency: ONE_HOUR }, + ); + + const { + vaultFactory: { vaultFactory, aethCollateralManager }, + aethTestPriceAuthority, + reserveKit: { reserveCreatorFacet, reservePublicFacet }, + auctioneerKit, + chainStorage, + } = services; + + const { reserveTracker } = await getMetricTrackers({ + t, + collateralManager: aethCollateralManager, + reservePublicFacet, + }); + + await E(reserveCreatorFacet).addIssuer(aeth.issuer, 'Aeth'); + + const collateralAmount = aeth.make(400n); + const wantMinted = run.make(1600n); + + const vaultSeat = await openVault({ + t, + cm: aethCollateralManager, + collateralAmount, + colKeyword: 'aeth', + wantMintedAmount: wantMinted, + }); + + // A bidder places a bid + const bidAmount = run.make(2000n); + const desired = aeth.make(400n); + const bidderSeat = await bid(t, zoe, auctioneerKit, aeth, bidAmount, desired); + + const { + vault, + publicNotifiers: { vault: vaultNotifier }, + } = await legacyOfferResult(vaultSeat); + + // Check that no child node with auction start time's name created before the liquidation + const vstorageBeforeLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageBeforeLiquidation.length, 0); + + setBlockMakeChildNode('3600'); + + // drop collateral price from 5:1 to 4:1 and liquidate vault + aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + + const { startTime } = await startAuctionClock(auctioneerKit, manualTimer); + + // Check that no child node with auction start time's name created after the liquidation + const vstorageDuringLiquidation = await getDataFromVstorage( + chainStorage, + `vaultFactory.managers.manager0.liquidations`, + ); + t.is(vstorageDuringLiquidation.length, 0); + + await assertVaultState(t, vaultNotifier, 'liquidating'); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertVaultCurrentDebt(t, vault, wantMinted); + + const currentTime = await setClockAndAdvanceNTimes( + manualTimer, + 2, + startTime, + 2n, + ); + trace(`advanced time to `, currentTime); + + await assertVaultState(t, vaultNotifier, 'liquidated'); + await assertVaultSeatExited(t, vaultSeat); + await assertVaultLocked(t, vaultNotifier, 0n, aeth); + await assertVaultCurrentDebt(t, vault, 0n); + await assertVaultFactoryRewardAllocation(t, vaultFactory, 80n); + + const closeSeat = await closeVault({ t, vault }); + await E(closeSeat).getOfferResult(); + + await assertCollateralProceeds(t, closeSeat, aeth.makeEmpty(), aeth.issuer); + await assertVaultCollateral(t, vault, 0n, aeth); + await assertBidderPayout(t, bidderSeat, run, 320n, aeth, 400n); + + await assertReserveState(reserveTracker, 'like', { + allocations: { + Aeth: undefined, + Fee: undefined, + }, + }); +}); diff --git a/packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js b/packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js new file mode 100644 index 00000000000..02d58055885 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/test-visibilityAssertions.js @@ -0,0 +1,92 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { E, Far } from '@endo/far'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { makeMockChainStorageRoot } from '../supports.js'; +import { assertNodeInStorage, assertStorageData } from './assertions.js'; + +const writeToStorage = async (storageNode, data) => { + await E(storageNode).setValue( + JSON.stringify(defaultMarshaller.toCapData(harden(data))), + ); +}; + +test('storage-node-created', async t => { + const storageRoot = makeMockChainStorageRoot(); + + await assertNodeInStorage({ + t, + rootNode: storageRoot, + desiredNode: 'test', + expected: false, + }); + + const testNode = await E(storageRoot).makeChildNode('test'); + await writeToStorage(testNode, { dummy: 'foo' }); + + await assertNodeInStorage({ + t, + rootNode: storageRoot, + desiredNode: 'test', + expected: true, + }); +}); + +test('storage-assert-data', async t => { + const storageRoot = makeMockChainStorageRoot(); + const moolaKit = makeIssuerKit('Moola'); + + const testNode = await E(storageRoot).makeChildNode('dummyNode'); + await writeToStorage(testNode, { + moolaForReserve: AmountMath.make(moolaKit.brand, 100n), + }); + + await assertStorageData({ + t, + path: 'dummyNode', + storageRoot, + expected: { moolaForReserve: AmountMath.make(moolaKit.brand, 100n) }, + }); +}); + +test('map-test-auction', async t => { + const vaultData = makeScalarBigMapStore('Vaults'); + + vaultData.init( + Far('key', { getId: () => 1, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + vaultData.init( + Far('key1', { getId: () => 2, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + vaultData.init( + Far('key2', { getId: () => 3, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + vaultData.init( + Far('key3', { getId: () => 4, getPhase: () => 'liquidated' }), + harden({ + collateral: 19n, + debt: 18n, + }), + ); + + const preAuction = [...vaultData.entries()].map(([vault, data]) => [ + vault.getId(), + { ...data, phase: vault.getPhase() }, + ]); + t.log(preAuction); + + t.pass(); +}); diff --git a/packages/inter-protocol/test/liquidationVisibility/tools.js b/packages/inter-protocol/test/liquidationVisibility/tools.js new file mode 100644 index 00000000000..7ac97e98602 --- /dev/null +++ b/packages/inter-protocol/test/liquidationVisibility/tools.js @@ -0,0 +1,480 @@ +import { E } from '@endo/eventual-send'; +import { makeIssuerKit } from '@agoric/ertp'; +import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; +import { allValues, makeTracer, objectMap } from '@agoric/internal'; +import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; +import { + makeRatio, + makeRatioFromAmounts, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { TimeMath } from '@agoric/time'; +import { subscribeEach } from '@agoric/notifier'; +import '../../src/vaultFactory/types.js'; +import { withAmountUtils } from '../supports.js'; +import { getRunFromFaucet } from '../vaultFactory/vaultFactoryUtils.js'; +import { subscriptionTracker, vaultManagerMetricsTracker } from '../metrics.js'; +import { startVaultFactory } from '../../src/proposals/econ-behaviors.js'; +import { setupElectorateReserveAndAuction } from './mock-setupChainStorage.js'; + +export const BASIS_POINTS = 10000n; + +let contractRoots = { + faucet: './test/vaultFactory/faucet.js', + VaultFactory: './src/vaultFactory/vaultFactory.js', + reserve: './src/reserve/assetReserve.js', + auctioneer: './src/auction/auctioneer.js', +}; + +const trace = makeTracer('VisibilityTools', true); + +export const setupBasics = async (zoe, contractsWrapper) => { + const stableIssuer = await E(zoe).getFeeIssuer(); + const stableBrand = await E(stableIssuer).getBrand(); + + // @ts-expect-error missing mint + const run = withAmountUtils({ issuer: stableIssuer, brand: stableBrand }); + const aeth = withAmountUtils( + makeIssuerKit('aEth', 'nat', { decimalPlaces: 6 }), + ); + const abtc = withAmountUtils( + makeIssuerKit('aBtc', 'nat', { decimalPlaces: 6 }), + ); + + if (contractsWrapper) { + contractRoots = { ...contractRoots, ...contractsWrapper }; + } + + const bundleCache = await unsafeMakeBundleCache('./bundles/'); + const bundles = await allValues({ + faucet: bundleCache.load(contractRoots.faucet, 'faucet'), + VaultFactory: bundleCache.load(contractRoots.VaultFactory, 'VaultFactory'), + reserve: bundleCache.load(contractRoots.reserve, 'reserve'), + auctioneer: bundleCache.load(contractRoots.auctioneer, 'auction'), + }); + const installation = objectMap(bundles, bundle => E(zoe).install(bundle)); + + return { + run, + aeth, + abtc, + bundleCache, + bundles, + installation, + }; +}; + +/** + * @typedef {Record & { + * aeth: IssuerKit & import('../supports.js').AmountUtils; + * run: IssuerKit & import('../supports.js').AmountUtils; + * bundleCache: Awaited>; + * rates: VaultManagerParamValues; + * interestTiming: InterestTiming; + * zoe: ZoeService; + * }} Context + */ + +/** + * NOTE: called separately by each test so zoe/priceAuthority don't interfere + * This helper function will economicCommittee, reserve and auctioneer. It will + * start the vaultFactory and open a new vault with the collateral provided in + * the context. The collateral value will be set by the priceAuthority with the + * ratio provided by priceOrList + * + * @param {import('ava').ExecutionContext} t + * @param {NatValue[] | Ratio} priceOrList + * @param {Amount | undefined} unitAmountIn + * @param {import('@agoric/time').TimerService} timer + * @param {RelativeTime} quoteInterval + * @param {Partial} [auctionParams] + * @param setupExtraAsset + */ +export const setupServices = async ( + t, + priceOrList, + unitAmountIn, + timer = buildManualTimer(), + quoteInterval = 1n, + auctionParams = {}, + setupExtraAsset = false, +) => { + const { + zoe, + run, + aeth, + abtc, + interestTiming, + minInitialDebt, + referencedUi, + rates, + } = t.context; + + t.context.timer = timer; + + const btcKit = setupExtraAsset + ? { + btc: abtc, + btcPrice: makeRatio(50n, run.brand, 10n, abtc.brand), + btcAmountIn: abtc.make(400n), + } + : undefined; + + const { + space, + priceAuthorityAdmin, + aethTestPriceAuthority, + abtcTestPriceAuthority, + } = await setupElectorateReserveAndAuction( + t, + // @ts-expect-error inconsistent types with withAmountUtils + run, + aeth, + priceOrList, + quoteInterval, + unitAmountIn, + auctionParams, + btcKit, + ); + + const { + consume, + installation: { produce: iProduce }, + } = space; + + iProduce.VaultFactory.resolve(t.context.installation.VaultFactory); + iProduce.liquidate.resolve(t.context.installation.liquidate); + + await startVaultFactory( + space, + { interestTiming, options: { referencedUi } }, + minInitialDebt, + ); + + const governorCreatorFacet = E.get( + consume.vaultFactoryKit, + ).governorCreatorFacet; + const vaultFactoryCreatorFacetP = E.get(consume.vaultFactoryKit).creatorFacet; + + const reserveCreatorFacet = E.get(consume.reserveKit).creatorFacet; + const reservePublicFacet = E.get(consume.reserveKit).publicFacet; + const reserveKit = { reserveCreatorFacet, reservePublicFacet }; + + const aethVaultManagerP = E(vaultFactoryCreatorFacetP).addVaultType( + aeth.issuer, + 'AEth', + rates, + ); + + let abtcVaultManagerP; + if (setupExtraAsset) { + await eventLoopIteration(); + abtcVaultManagerP = E(vaultFactoryCreatorFacetP).addVaultType( + abtc.issuer, + 'ABtc', + rates, + ); + } + + /** @typedef {import('../../src/proposals/econ-behaviors.js').AuctioneerKit} AuctioneerKit */ + /** @typedef {import('@agoric/zoe/tools/manualPriceAuthority.js').ManualPriceAuthority} ManualPriceAuthority */ + /** @typedef {import('../../src/vaultFactory/vaultFactory.js').VaultFactoryContract} VFC */ + /** + * @type {[ + * any, + * VaultFactoryCreatorFacet, + * VFC['publicFacet'], + * VaultManager, + * VaultManager | undefined, + * AuctioneerKit, + * ManualPriceAuthority, + * CollateralManager, + * CollateralManager | undefined, + * chainStorage, + * board, + * ]} + */ + const [ + governorInstance, + vaultFactory, // creator + vfPublic, + aethVaultManager, + abtcVaultManager, + auctioneerKit, + priceAuthority, + aethCollateralManager, + abtcCollateralManager, + chainStorage, + board, + ] = await Promise.all([ + E(consume.agoricNames).lookup('instance', 'VaultFactoryGovernor'), + vaultFactoryCreatorFacetP, + E.get(consume.vaultFactoryKit).publicFacet, + aethVaultManagerP, + abtcVaultManagerP || Promise.resolve(undefined), + consume.auctioneerKit, + /** @type {Promise} */ (consume.priceAuthority), + E(aethVaultManagerP).getPublicFacet(), + abtcVaultManagerP + ? E(abtcVaultManagerP).getPublicFacet() + : Promise.resolve(undefined), + consume.chainStorage, + consume.board, + ]); + trace(t, 'pa', { + governorInstance, + vaultFactory, + vfPublic, + priceAuthority: !!priceAuthority, + }); + + const { g, v } = { + g: { + governorInstance, + governorPublicFacet: E(zoe).getPublicFacet(governorInstance), + governorCreatorFacet, + }, + v: { + vaultFactory, + vfPublic, + aethVaultManager, + aethCollateralManager, + abtcVaultManager, + abtcCollateralManager, + }, + }; + + await E(auctioneerKit.creatorFacet).addBrand(aeth.issuer, 'Aeth'); + if (setupExtraAsset) { + await E(auctioneerKit.creatorFacet).addBrand(abtc.issuer, 'ABtc'); + } + + return { + zoe, + timer, + space, + governor: g, + vaultFactory: v, + runKit: { issuer: run.issuer, brand: run.brand }, + priceAuthority, + reserveKit, + auctioneerKit, + priceAuthorityAdmin, + aethTestPriceAuthority, + abtcTestPriceAuthority, + chainStorage, + board, + }; +}; + +export const setClockAndAdvanceNTimes = async ( + timer, + times, + start, + incr = 1n, +) => { + let currentTime = start; + // first time through is at START, then n TIMES more plus INCR + for (let i = 0; i <= times; i += 1) { + await timer.advanceTo(TimeMath.absValue(currentTime)); + await eventLoopIteration(); + currentTime = TimeMath.addAbsRel(currentTime, TimeMath.relValue(incr)); + } + return currentTime; +}; + +// Calculate the nominalStart time (when liquidations happen), and the priceLock +// time (when prices are locked). Advance the clock to the priceLock time, then +// to the nominal start time. return the nominal start time and the auction +// start time, so the caller can check on liquidations in process before +// advancing the clock. +export const startAuctionClock = async (auctioneerKit, manualTimer) => { + const schedule = await E(auctioneerKit.creatorFacet).getSchedule(); + const priceDelay = await E(auctioneerKit.publicFacet).getPriceLockPeriod(); + const { startTime, startDelay, endTime } = schedule.nextAuctionSchedule; + const nominalStart = TimeMath.subtractAbsRel(startTime, startDelay); + const priceLockTime = TimeMath.subtractAbsRel(nominalStart, priceDelay); + await manualTimer.advanceTo(TimeMath.absValue(priceLockTime)); + await eventLoopIteration(); + + await manualTimer.advanceTo(TimeMath.absValue(nominalStart)); + await eventLoopIteration(); + return { startTime, time: nominalStart, endTime }; +}; + +export const bid = async (t, zoe, auctioneerKit, aeth, bidAmount, desired) => { + const bidderSeat = await E(zoe).offer( + E(auctioneerKit.publicFacet).makeBidInvitation(aeth.brand), + harden({ give: { Bid: bidAmount } }), + harden({ Bid: getRunFromFaucet(t, bidAmount.value) }), + { maxBuy: desired, offerPrice: makeRatioFromAmounts(bidAmount, desired) }, + ); + return bidderSeat; +}; + +/** + * @typedef {object} OpenVaultParams + * @property {any} t + * @property {CollateralManager} cm + * @property {Amount<'nat'>} collateralAmount + * @property {string} colKeyword + * @property {Amount<'nat'>} wantMintedAmount + */ + +/** + * @param {OpenVaultParams} params + * @returns {Promise>} + */ +export const openVault = async ({ + t, + cm, + collateralAmount, + colKeyword, + wantMintedAmount, +}) => { + return E(t.context.zoe).offer( + await E(cm).makeVaultInvitation(), + harden({ + give: { Collateral: collateralAmount }, + want: { Minted: wantMintedAmount }, + }), + harden({ + Collateral: t.context[colKeyword].mint.mintPayment(collateralAmount), + }), + ); +}; + +/** + * @typedef {object} AdjustVaultParams + * @property {object} t + * @property {Vault} vault + * @property {{ + * want: [ + * { + * Collateral: Amount<'nat'>; + * Minted: Amount<'nat'>; + * }, + * ]; + * give: [ + * { + * Collateral: Amount<'nat'>; + * Minted: Amount<'nat'>; + * }, + * ]; + * }} proposal + * @property {{ + * want: [ + * { + * Collateral: Payment; + * Minted: Payment; + * }, + * ]; + * give: [ + * { + * Collateral: Payment; + * Minted: Payment; + * }, + * ]; + * }} [payment] + */ + +/** + * @param {AdjustVaultParams} adjustVaultParams + * @returns {Promise} + */ +export const adjustVault = async ({ t, vault, proposal, payment }) => { + return E(t.context.zoe).offer( + E(vault).makeAdjustBalancesInvitation(), + harden(proposal), + payment, + ); +}; + +/** + * @typedef {object} CloseVaultParams + * @property {Vault} vault + * @property {object} t + */ + +/** + * @param {CloseVaultParams} closeVaultParams + * @returns {Promise} + */ +export const closeVault = async ({ t, vault }) => { + return E(t.context.zoe).offer(E(vault).makeCloseInvitation()); +}; + +/** + * @typedef {object} GetTrackerParams + * @property {any} t + * @property {CollateralManager} collateralManager + * @property {AssetReservePublicFacet} reservePublicFacet + */ + +/** + * @typedef {object} Trackers + * @property {object} [reserveTracker] + * @property {object} [collateralManagerTracker] + */ + +/** + * @param {GetTrackerParams} getTrackerParams + * @returns {Promise} + */ +export const getMetricTrackers = async ({ + t, + collateralManager, + reservePublicFacet, +}) => { + /** @type {Trackers} */ + const trackers = {}; + if (reservePublicFacet) { + const metricsTopic = await E.get(E(reservePublicFacet).getPublicTopics()) + .metrics; + trackers.reserveTracker = await subscriptionTracker(t, metricsTopic); + } + + if (collateralManager) { + trackers.collateralManagerTracker = await vaultManagerMetricsTracker( + t, + collateralManager, + ); + } + + return harden(trackers); +}; + +export const getBookDataTracker = async (t, auctioneerPublicFacet, brand) => { + const tracker = E.when( + E(auctioneerPublicFacet).getBookDataUpdates(brand), + subscription => subscriptionTracker(t, subscribeEach(subscription)), + ); + + return tracker; +}; + +export const getSchedulerTracker = async (t, auctioneerPublicFacet) => { + const tracker = E.when( + E(auctioneerPublicFacet).getPublicTopics(), + subscription => + subscriptionTracker(t, subscribeEach(subscription.schedule.subscriber)), + ); + + return tracker; +}; + +export const getDataFromVstorage = async (storage, node) => { + const illustration = [...storage.keys()].sort().map( + /** @type {(k: string) => [string, unknown]} */ + key => [ + key.replace('mockChainStorageRoot.', 'published.'), + storage.getBody(key), + ], + ); + + const pruned = illustration.filter( + node ? ([key, _]) => key.startsWith(`published.${node}`) : _entry => true, + ); + + return pruned; +}; diff --git a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js index 6ca8c2fdebe..5ab09d3ade1 100644 --- a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js +++ b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js @@ -237,12 +237,15 @@ const setupServices = async ( return { zoe, + timer, governor: g, vaultFactory: v, runKit: { issuer: run.issuer, brand: run.brand }, priceAuthority, reserveKit, auctioneerKit, + priceAuthorityAdmin, + aethTestPriceAuthority, }; }; diff --git a/packages/internal/src/utils.js b/packages/internal/src/utils.js index bbc2954fb50..d5ca684c8e5 100644 --- a/packages/internal/src/utils.js +++ b/packages/internal/src/utils.js @@ -349,6 +349,21 @@ export const allValues = async obj => { return harden(fromEntries(zip(keys(obj), resolved))); }; +/** + * Just like allValues above but use this when you want to silently handle rejected promises + * and still keep using the values of resolved ones. + * + * @type + * { >>(obj: T) => Promise<{ [K in keyof T]: Awaited}> } + */ +export const allValuesSettled = async obj => { + const resolved = await Promise.allSettled(values(obj)); + // @ts-expect-error + const valuesMapped = resolved.map(({ value }) => value); + // @ts-expect-error cast + return harden(fromEntries(zip(keys(obj), valuesMapped))); +}; + /** * A tee implementation where all readers are synchronized with each other. * They all consume the source stream in lockstep, and any one returning or diff --git a/packages/internal/test/test-utils.js b/packages/internal/test/test-utils.js index 4e64c0e1632..41f4c6a9319 100644 --- a/packages/internal/test/test-utils.js +++ b/packages/internal/test/test-utils.js @@ -13,6 +13,7 @@ import { forever, deeplyFulfilledObject, synchronizedTee, + allValuesSettled, } from '../src/utils.js'; test('fromUniqueEntries', t => { @@ -263,3 +264,17 @@ test('synchronizedTee - consume synchronized', async t => { t.deepEqual(output1, sourceData.slice(0, i)); t.deepEqual(output2, sourceData.slice(0, i)); }); + +test('allValuesSettled', async t => { + const result = await allValuesSettled({ + promiseOne: Promise.resolve('I am a happy promise - One'), + promiseTwo: Promise.reject(new Error('I am an upset promise')), + promiseThree: Promise.resolve('I am a happy promise - Three'), + }); + + t.deepEqual(result, { + promiseOne: 'I am a happy promise - One', + promiseTwo: undefined, + promiseThree: 'I am a happy promise - Three', + }); +}); diff --git a/packages/vats/test/bootstrapTests/test-liquidation-visibility.ts b/packages/vats/test/bootstrapTests/test-liquidation-visibility.ts new file mode 100644 index 00000000000..adc3f2f9562 --- /dev/null +++ b/packages/vats/test/bootstrapTests/test-liquidation-visibility.ts @@ -0,0 +1,442 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { ExecutionContext, TestFn } from 'ava'; +import { ScheduleNotification } from '@agoric/inter-protocol/src/auction/scheduler.js'; +import { NonNullish } from '@agoric/assert/src/assert.js'; +import { TimeMath } from '@agoric/time/src/timeMath.js'; +import { TimestampRecord } from '@agoric/time/src/types'; +import { EconomyBootstrapSpace } from '@agoric/inter-protocol/src/proposals/econ-behaviors.js'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import { + ensureVaultCollateral, + LiquidationSetup, + LiquidationTestContext, + makeLiquidationTestContext, + scale6, +} from '../../tools/liquidation.ts'; + +export type LiquidationOutcome = { + reserve: { + allocations: Record; + shortfall: number; + }; + vaults: { + locked: number; + }[]; +}; + +const test = anyTest as TestFn; + +type AnyFunction = (...args: any[]) => any; + +//#region Product spec +const setup: LiquidationSetup = { + // Vaults are sorted in the worst debt/col ratio to the best + vaults: [ + { + atom: 15, + ist: 105, + debt: 105.525, + }, + { + atom: 15, + ist: 103, + debt: 103.515, + }, + { + atom: 15, + ist: 100, + debt: 100.5, + }, + ], + bids: [ + { + give: '80IST', + discount: 0.1, + }, + { + give: '90IST', + price: 9.0, + }, + { + give: '150IST', + discount: 0.15, + }, + ], + price: { + starting: 12.34, + trigger: 9.99, + }, + auction: { + start: { + collateral: 45, + debt: 309.54, + }, + end: { + collateral: 9.659301, + debt: 0, + }, + }, +}; + +const outcome: LiquidationOutcome = { + reserve: { + allocations: { + ATOM: 0.309852, + STARS: 0.309852, + }, + shortfall: 0, + }, + // The order in the setup preserved + vaults: [ + { + locked: 2.846403, + }, + { + locked: 3.0779, + }, + { + locked: 3.425146, + }, + ], +}; +//#endregion + +const runAuction = async (runUtils, advanceTimeBy) => { + const { EV } = runUtils; + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const { liveAuctionSchedule } = await EV( + auctioneerKit.publicFacet, + ).getSchedules(); + + await advanceTimeBy(3 * Number(liveAuctionSchedule.steps), 'minutes'); + + return liveAuctionSchedule; +}; + +const startAuction = async (t: ExecutionContext) => { + const { readLatest, advanceTimeTo } = t.context; + + const scheduleNotification: ScheduleNotification = readLatest( + 'published.auction.schedule', + ); + + await advanceTimeTo(NonNullish(scheduleNotification.nextStartTime)); +}; + +const addNewVaults = async ({ + t, + collateralBrandKey, + base, +}: { + t: ExecutionContext; + collateralBrandKey: string; + base: number; +}) => { + const { walletFactoryDriver, priceFeedDrivers, placeBids } = t.context; + + await priceFeedDrivers[collateralBrandKey].setPrice(setup.price.starting); + const minter = await walletFactoryDriver.provideSmartWallet('agoric1minter'); + + for (let i = 0; i < setup.vaults.length; i += 1) { + const offerId = `open-${collateralBrandKey}-vault${base + i}`; + await minter.executeOfferMaker(Offers.vaults.OpenVault, { + offerId, + collateralBrandKey, + wantMinted: setup.vaults[i].ist, + giveCollateral: setup.vaults[i].atom, + }); + t.like(minter.getLatestUpdateRecord(), { + updated: 'offerStatus', + status: { id: offerId, numWantsSatisfied: 1 }, + }); + } + + await placeBids(collateralBrandKey, 'agoric1buyer', setup, base); + await priceFeedDrivers[collateralBrandKey].setPrice(setup.price.trigger); + await startAuction(t); +}; + +const initVaults = async ({ + t, + collateralBrandKey, + managerIndex, +}: { + t: ExecutionContext; + collateralBrandKey: string; + managerIndex: number; +}) => { + const { setupVaults, placeBids, priceFeedDrivers, readLatest } = t.context; + + const metricsPath = `published.vaultFactory.managers.manager${managerIndex}.metrics`; + + await setupVaults(collateralBrandKey, managerIndex, setup); + await placeBids(collateralBrandKey, 'agoric1buyer', setup); + + await priceFeedDrivers[collateralBrandKey].setPrice(setup.price.trigger); + await startAuction(t); + + t.like(readLatest(metricsPath), { + numActiveVaults: 0, + numLiquidatingVaults: setup.vaults.length, + liquidatingCollateral: { + value: scale6(setup.auction.start.collateral), + }, + liquidatingDebt: { value: scale6(setup.auction.start.debt) }, + lockedQuote: null, + }); +}; + +test.before(async t => { + t.context = await makeLiquidationTestContext(t); +}); + +const checkVisibility = async ({ + t, + managerIndex, + setupCallback, + base = 0, +}: { + t: ExecutionContext; + managerIndex: number; + setupCallback: AnyFunction; + base?: number; +}) => { + const { readLatest, advanceTimeBy, runUtils } = t.context; + + await setupCallback(); + + const { startTime, startDelay, endTime } = await runAuction( + runUtils, + advanceTimeBy, + ); + const nominalStart: Timestamp = TimeMath.subtractAbsRel( + startTime, + startDelay, + ) as TimestampRecord; + t.log(nominalStart); + + const visibilityPath = `published.vaultFactory.managers.manager${managerIndex}.liquidations.${nominalStart.absValue.toString()}`; + const preAuction = readLatest(`${visibilityPath}.vaults.preAuction`); + const postAuction = readLatest(`${visibilityPath}.vaults.postAuction`); + const auctionResult = readLatest(`${visibilityPath}.auctionResult`); + + const expectedPreAuction: [ + string, + { + collateralAmount: { value: bigint }; + debtAmount: { value: bigint }; + }, + ][] = []; + for (let i = 0; i < setup.vaults.length; i += 1) { + expectedPreAuction.push([ + `vault${base + i}`, + { + collateralAmount: { value: scale6(setup.vaults[i].atom) }, + debtAmount: { value: scale6(setup.vaults[i].debt) }, + }, + ]); + } + t.like(preAuction, expectedPreAuction); + + const expectedPostAuction: [ + string, + { Collateral?: { value: bigint }; Minted?: { value: bigint } }, + ][] = []; + // Iterate from the end because we expect the post auction vaults + // in best to worst order. + for (let i = outcome.vaults.length - 1; i >= 0; i -= 1) { + expectedPostAuction.push([ + `vault${base + i}`, + { Collateral: { value: scale6(outcome.vaults[i].locked) } }, + ]); + } + t.like(postAuction, expectedPostAuction); + + t.like(auctionResult, { + collateralOffered: { value: scale6(setup.auction.start.collateral) }, + istTarget: { value: scale6(setup.auction.start.debt) }, + collateralForReserve: { value: scale6(outcome.reserve.allocations.ATOM) }, + shortfallToReserve: { value: 0n }, + mintedProceeds: { value: scale6(setup.auction.start.debt) }, + collateralSold: { + value: + scale6(setup.auction.start.collateral) - + scale6(setup.auction.end.collateral), + }, + collateralRemaining: { value: 0n }, + endTime: { absValue: endTime.absValue }, + }); + + t.log('preAuction', preAuction); + t.log('postAuction', postAuction); + t.log('auctionResult', auctionResult); +}; + +/** + * @file In this file we test the below scenario: + * - Alice opens a vault + * - Alice gets liquidated + * - Visibility data correctly observed in storage + * - Vault factory gets restarted + * - An auction starts with no vaults to liquidate + * - No unnecessary storage node is created when `liquidateVaults` is invoked with no vaults to liquidate + * - Bob opens a vault + * - Bob gets liquidated + * - Visibility data correctly observed in storage + */ +test.serial('visibility-before-upgrade', async t => { + await checkVisibility({ + t, + managerIndex: 0, + setupCallback: () => + initVaults({ + t, + collateralBrandKey: 'ATOM', + managerIndex: 0, + }), + }); +}); + +test.serial('add-STARS-collateral', async t => { + await ensureVaultCollateral('STARS', t); + await t.context.setupStartingState({ + collateralBrandKey: 'STARS', + managerIndex: 1, + price: setup.price.starting, + }); + t.pass(); // reached here without throws +}); + +test.serial('restart-vault-factory', async t => { + const { + runUtils: { EV }, + } = t.context; + const vaultFactoryKit = await (EV.vat('bootstrap').consumeItem( + 'vaultFactoryKit', + ) as EconomyBootstrapSpace['consume']['vaultFactoryKit']); + + // @ts-expect-error cast XXX missing from type + const { privateArgs } = vaultFactoryKit; + console.log('reused privateArgs', privateArgs, vaultFactoryKit); + + const vfAdminFacet = await EV( + vaultFactoryKit.governorCreatorFacet, + ).getAdminFacet(); + + t.log('awaiting VaultFactory restartContract'); + const upgradeResult = await EV(vfAdminFacet).restartContract(privateArgs); + t.deepEqual(upgradeResult, { incarnationNumber: 1 }); +}); + +test.serial('restart contractGovernor', async t => { + const { EV } = t.context.runUtils; + const vaultFactoryKit = await (EV.vat('bootstrap').consumeItem( + 'vaultFactoryKit', + ) as EconomyBootstrapSpace['consume']['vaultFactoryKit']); + + const { governorAdminFacet } = vaultFactoryKit; + // has no privateArgs of its own. the privateArgs.governed is only for the + // contract startInstance. any changes to those privateArgs have to happen + // through a restart or upgrade using the governed contract's adminFacet + const privateArgs = undefined; + + t.log('awaiting CG restartContract'); + const upgradeResult = + await EV(governorAdminFacet).restartContract(privateArgs); + t.deepEqual(upgradeResult, { incarnationNumber: 1 }); +}); + +test.serial('no-unnecessary-storage-nodes', async t => { + const { + runUtils: { EV }, + readLatest, + } = t.context; + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const { nextAuctionSchedule } = await EV( + auctioneerKit.publicFacet, + ).getSchedules(); + t.log('nextAuctionSchedule', nextAuctionSchedule); + await startAuction(t); + + const scheduleNotification = readLatest('published.auction.schedule'); + t.log('scheduleNotification', scheduleNotification); + + // Make sure the auction started properly + t.is( + nextAuctionSchedule.startTime.absValue, + scheduleNotification.activeStartTime.absValue, + ); + + t.throws( + () => + readLatest( + `published.vaultFactory.managers.manager0.liquidations.${scheduleNotification.activeStartTime.absValue.toString()}`, + ), + { + message: `no data for "published.vaultFactory.managers.manager0.liquidations.${scheduleNotification.activeStartTime.absValue.toString()}"`, + }, + ); +}); + +test.serial('visibility-after-upgrade', async t => { + await checkVisibility({ + t, + managerIndex: 0, + setupCallback: () => + addNewVaults({ + t, + collateralBrandKey: 'ATOM', + base: setup.vaults.length, + }), + base: 3, + }); +}); + +test.serial('here-check-STARS-visibility', async t => { + await checkVisibility({ + t, + managerIndex: 1, + setupCallback: () => + addNewVaults({ + t, + collateralBrandKey: 'STARS', + base: 0, + }), + }); +}); + +test.serial('snapshot-storage', async t => { + const { readLatest } = t.context; + + const buildSnapshotItem = ( + paths: string[], + managerIndex: number, + auctionTime: bigint, + ) => { + const basePath = `published.vaultFactory.managers.manager${managerIndex}.liquidations.${auctionTime}`; + const item = {}; + for (const path of paths) { + const exactPath = `${basePath}.${path}`; + item[exactPath] = readLatest(exactPath); + } + t.snapshot(Object.entries(item)); + }; + + buildSnapshotItem( + ['vaults.preAuction', 'vaults.postAuction', 'auctionResult'], + 0, + 3600n, + ); + + buildSnapshotItem( + ['vaults.preAuction', 'vaults.postAuction', 'auctionResult'], + 0, + 10800n, + ); + + buildSnapshotItem( + ['vaults.preAuction', 'vaults.postAuction', 'auctionResult'], + 1, + 14400n, + ); +}); From 6b7247dd9f769d75ab88f2b9fd2acb2c58da2c67 Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Fri, 16 Feb 2024 16:41:06 +0300 Subject: [PATCH 02/12] fix(liquidationVisibility): fix dependency errors --- .../test/liquidationVisibility/mock-setupChainStorage.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js index 21fe87ea705..3837af7bd49 100644 --- a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js +++ b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js @@ -8,21 +8,21 @@ import '../../src/vaultFactory/types.js'; import '@agoric/zoe/exported.js'; import { makeManualPriceAuthority } from '@agoric/zoe/tools/manualPriceAuthority.js'; import { makeScalarBigMapStore } from '@agoric/vat-data/src/index.js'; -import { providePriceAuthorityRegistry } from '@agoric/vats/src/priceAuthorityRegistry.js'; +import { providePriceAuthorityRegistry } from '@agoric/zoe/tools/priceAuthorityRegistry.js'; import { makeScriptedPriceAuthority } from '@agoric/zoe/tools/scriptedPriceAuthority.js'; import * as utils from '@agoric/vats/src/core/utils.js'; import { makePromiseSpace, makeAgoricNamesAccess } from '@agoric/vats'; import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; import { produceDiagnostics } from '@agoric/vats/src/core/basic-behaviors.js'; import { Far } from '@endo/far'; -import { unmarshalFromVstorage } from '@agoric/internal/src/marshal.js'; import { bindAllMethods } from '@agoric/internal/src/method-tools.js'; import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; import { isStreamCell, assertPathSegment, + unmarshalFromVstorage, } from '@agoric/internal/src/lib-chainStorage.js'; -import { makeHeapZone } from '@agoric/base-zone/heap.js'; +import { heapZone } from '@agoric/zone'; import * as cb from '@agoric/internal/src/callback.js'; import { installPuppetGovernance, produceInstallations } from '../supports.js'; import { startEconomicCommittee } from '../../src/proposals/startEconCommittee.js'; @@ -195,7 +195,7 @@ const prepareChainStorageNode = zone => { return makeChainStorageNode; }; -const makeHeapChainStorageNode = prepareChainStorageNode(makeHeapZone()); +const makeHeapChainStorageNode = prepareChainStorageNode(heapZone); /** * Create a heap-based root storage node for a given backing function and root From f0ed85b43813d46420db77012ba8e28da49e679a Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Fri, 16 Feb 2024 16:47:38 +0300 Subject: [PATCH 03/12] fix(liquidationVisibility): fix test-vaultLiquidation.js --- .../inter-protocol/test/vaultFactory/test-vaultLiquidation.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js index 5ab09d3ade1..6ca8c2fdebe 100644 --- a/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js +++ b/packages/inter-protocol/test/vaultFactory/test-vaultLiquidation.js @@ -237,15 +237,12 @@ const setupServices = async ( return { zoe, - timer, governor: g, vaultFactory: v, runKit: { issuer: run.issuer, brand: run.brand }, priceAuthority, reserveKit, auctioneerKit, - priceAuthorityAdmin, - aethTestPriceAuthority, }; }; From e17e114f6436252ce795671b38c3b0f355ee4a5f Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Mon, 19 Feb 2024 11:52:37 +0300 Subject: [PATCH 04/12] fix(liquidationVisibility): remove unused boot package --- .../test-liquidation-visibility.ts.md | 472 ------------------ .../test-liquidation-visibility.ts.snap | Bin 2400 -> 0 bytes packages/boot/tools/liquidation.ts | 426 ---------------- 3 files changed, 898 deletions(-) delete mode 100644 packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md delete mode 100644 packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap delete mode 100644 packages/boot/tools/liquidation.ts diff --git a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md deleted file mode 100644 index 51c2a235df5..00000000000 --- a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.md +++ /dev/null @@ -1,472 +0,0 @@ -# Snapshot report for `test/bootstrapTests/test-liquidation-visibility.ts` - -The actual snapshot is saved in `test-liquidation-visibility.ts.snap`. - -Generated by [AVA](https://avajs.dev). - -## snapshot-storage - -> Snapshot 1 - - [ - [ - 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.preAuction', - [ - [ - 'vault0', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 105525000n, - }, - }, - ], - [ - 'vault1', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 103515000n, - }, - }, - ], - [ - 'vault2', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 100500000n, - }, - }, - ], - ], - ], - [ - 'published.vaultFactory.managers.manager0.liquidations.3600.vaults.postAuction', - [ - [ - 'vault2', - { - Collateral: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 3425146n, - }, - phase: 'liquidated', - }, - ], - [ - 'vault1', - { - Collateral: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 3077900n, - }, - phase: 'liquidated', - }, - ], - [ - 'vault0', - { - Collateral: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 2846403n, - }, - phase: 'liquidated', - }, - ], - ], - ], - [ - 'published.vaultFactory.managers.manager0.liquidations.3600.auctionResult', - { - collateralForReserve: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 309852n, - }, - collateralOffered: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 45000000n, - }, - collateralRemaining: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 0n, - }, - collateralSold: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 35340699n, - }, - endTime: { - absValue: 5042n, - timerBrand: Object @Alleged: BoardRemotetimerBrand { - getBoardId: Function getBoardId {}, - }, - }, - istTarget: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 309540000n, - }, - mintedProceeds: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 309540000n, - }, - shortfallToReserve: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 0n, - }, - }, - ], - ] - -> Snapshot 2 - - [ - [ - 'published.vaultFactory.managers.manager0.liquidations.10800.vaults.preAuction', - [ - [ - 'vault3', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 105525000n, - }, - }, - ], - [ - 'vault4', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 103515000n, - }, - }, - ], - [ - 'vault5', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 100500000n, - }, - }, - ], - ], - ], - [ - 'published.vaultFactory.managers.manager0.liquidations.10800.vaults.postAuction', - [ - [ - 'vault5', - { - Collateral: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 3425146n, - }, - phase: 'liquidated', - }, - ], - [ - 'vault4', - { - Collateral: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 3077900n, - }, - phase: 'liquidated', - }, - ], - [ - 'vault3', - { - Collateral: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 2846403n, - }, - phase: 'liquidated', - }, - ], - ], - ], - [ - 'published.vaultFactory.managers.manager0.liquidations.10800.auctionResult', - { - collateralForReserve: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 309852n, - }, - collateralOffered: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 45000000n, - }, - collateralRemaining: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 0n, - }, - collateralSold: { - brand: Object @Alleged: BoardRemoteATOM brand { - getBoardId: Function getBoardId {}, - }, - value: 35340699n, - }, - endTime: { - absValue: 12242n, - timerBrand: Object @Alleged: BoardRemotetimerBrand { - getBoardId: Function getBoardId {}, - }, - }, - istTarget: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 309540000n, - }, - mintedProceeds: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 309540000n, - }, - shortfallToReserve: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 0n, - }, - }, - ], - ] - -> Snapshot 3 - - [ - [ - 'published.vaultFactory.managers.manager1.liquidations.14400.vaults.preAuction', - [ - [ - 'vault0', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 105525000n, - }, - }, - ], - [ - 'vault1', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 103515000n, - }, - }, - ], - [ - 'vault2', - { - collateralAmount: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 15000000n, - }, - debtAmount: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 100500000n, - }, - }, - ], - ], - ], - [ - 'published.vaultFactory.managers.manager1.liquidations.14400.vaults.postAuction', - [ - [ - 'vault2', - { - Collateral: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 3425146n, - }, - phase: 'liquidated', - }, - ], - [ - 'vault1', - { - Collateral: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 3077900n, - }, - phase: 'liquidated', - }, - ], - [ - 'vault0', - { - Collateral: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 2846403n, - }, - phase: 'liquidated', - }, - ], - ], - ], - [ - 'published.vaultFactory.managers.manager1.liquidations.14400.auctionResult', - { - collateralForReserve: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 309852n, - }, - collateralOffered: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 45000000n, - }, - collateralRemaining: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 0n, - }, - collateralSold: { - brand: Object @Alleged: BoardRemoteSTARS brand { - getBoardId: Function getBoardId {}, - }, - value: 35340699n, - }, - endTime: { - absValue: 15842n, - timerBrand: Object @Alleged: BoardRemotetimerBrand { - getBoardId: Function getBoardId {}, - }, - }, - istTarget: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 309540000n, - }, - mintedProceeds: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 309540000n, - }, - shortfallToReserve: { - brand: Object @Alleged: BoardRemoteIST brand { - getBoardId: Function getBoardId {}, - }, - value: 0n, - }, - }, - ], - ] diff --git a/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap b/packages/boot/test/bootstrapTests/snapshots/test-liquidation-visibility.ts.snap deleted file mode 100644 index b3b15a5a857e58b84a56b39474c996243111fae8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2400 zcmV-m37_^sRzV(cy;X5zC=rD#TZ+QwpD94Z7XU6qX}cM4-k}=cI#M~O4Th}nbxk_N7-mq z0h)knFjZr1w|~yDeSXi$bCOz&W^yFW$))2Jw9<(t@-h6?~D8w>Kve z;xNyrx4d32$GbVcfa8mKep9L#TiYhS^UCH=CN^yoEel@5=3kLUKDV7MryanR09ui~ z3|Ps6hX5Q!e70Qh26lp&KnVyNS2;0PyN_QzFxVB1B>SXr?T|Pal~;+~I4{Gs{bCF! zCy`8V2({73y@L^)ph!HHtgWjTgj7MYb|4`&4|Y%dUOCYDw$w4_8*PmRqs5c5dbrkU zhWng};l$J!?NSnj%1A?#BjKWW7MsVAmyuVQb6&u_IfZ`*@HTQ0xx&ELFtE#Bz;VTh zAL&Gf5nPYlA>?<+`GUy?MQvSQl)7d9#>{3}S()l0^cAKr!li|oi?F*ebrEK$Z)T_? zS?bgz<-v?NzLe)3b}n;HE4$;-s3=PbF}fWD{EO%AK}JET~+Gt!@$z$w)ZS0pKSiiuuHR(83HR6YA6 zFEufY-BN8-Vpl)WKYMH|1V< zKpz3}J>;h>`UwsCM<9Pj-eJ)y4eE7r+%-tGlR-bJLEC|BL>^$#PifE{Kz1QdvuL#j zeFMl_$Q2g7MT6ep;)KZ*Z;kJ`pK;aJSzZ?>ygOc(SU&0sF2~CZmfU3bxV-1|a zkf402T8tTM!K-Wc8yvnaR2LNLo0`%sa#*L>mE54l3qwLMpp0`+HI7@x4KA@FHCSdQ z7mdxtg)XOs@GCg*YJ$^&>_&3>OVJX$)b~Ujw z(}TUx2l~XMl;P4JDJAu)9kQ&`ZVNi`N~dvkCwyR1-se^|@c~H)-YEzyiZ1NyL$-LC ztE)(}e)a(QF>;(mT^jTpkc&uB8H1K;&}tw7q?JXz8gu~25b`jKmTS<%K%PZTvS@_{ zeHY04NTrWKuhpPUKyF1gu;_IfbTg2x$YU(JFc+OSaY5v~Eeh>iH5gO=i^DHg{h+U6 ziQ$S`wjy&OKkqa4NcC%<=*1&i215Q|y{f~bMmq=%4NW<7_`bmofyQ8cP^eR$W+SK_Eoat+L*LUC<;PI^ zB3tg(`!IAUukPG!bUlrZXV|XubU*UvrC&35-GU10z8oTn-l+(3NjnYS1d0t;d@Q#j zZRkcmBtzFk8M+n#w;}r|L-!Pb=a5RnhHi$=%r7)fspu9=3)ID6`@|SBeCY&m;FEPasE-lgK-IC0%7kzb(t^ zw;oo~eJev9%~GdXN$1uj=|Z%m`+^=x_YFeQJw27AJ3Y0eD z19$}4OG&yz0FENBP?GL10RDlzOG!E#?p;n~0VU~f1|T4xr6gTDfQ?9;mUQ0%vI99t zOS(S+`5SVHmUQJ#j=L7Qg_d;df!v8~r6t`6kWu70TGE{cat8T;mUN3<9Jd5nF=I(r zr>sDclx4w`V<)+g!abvtdsFAQ9#Yb!j-!#4blqkp-IL}e-H%L2x}VLeq_g`2fk{jE z6lv)$Szzh>7FfE58Ckkzs9K3gw58h$}FQZRuPk9Op%r(3Y+P$Q{Ta zZRtjUj3UQqOLqpyc`GfQ%Y!4DxusizQon_kZVhS2zC;?fQL@MH$sYe9dn_U?U7KZY z?LNyo?Xk?#og^KcZ7K(sA%fFbx*qI{BHL(7w-3kxAq{)(mig%((Oj=-kDpvU!(M8%Prm8==e`7EuDjOa7`p} z&(&z@elTb0G#bm#XslpxRxRDM!_9)F%RAnjrJJ*KbCzz-(tXI5PS9!T{5maNK&Pb( z=(Kcxot93RB}+GI-qP(eVd)OcvZV_(PFlJnq@}xTfu##rVCfoXWa*Zpss-txE!|gu zY)AIfmhJ?QapVGR>D(n8SB6y6maY@XCS-`VbUT5JA-|$6-B}seB`989DbT+ql zP@2x|!6D6D(^aE1V4t8$IYWP-Etr;$epyN`!bLRk&&xg(;YXj>1-BgI)i@+mv0&d{}3*(rZf1LaQUWT z@DJhgYPyZ4HQi&THQi1Vnr;lW`}1fzU5{7PpFr)n<(}>=I$p@*>GVGIwW7{VYPe66 SMC@u3b^iy8THw_kI{*Nfz>cv1 diff --git a/packages/boot/tools/liquidation.ts b/packages/boot/tools/liquidation.ts deleted file mode 100644 index 907916f7788..00000000000 --- a/packages/boot/tools/liquidation.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { Fail } from '@agoric/assert'; -import { - SECONDS_PER_HOUR, - SECONDS_PER_MINUTE, -} from '@agoric/inter-protocol/src/proposals/econ-behaviors.js'; -import { - AgoricNamesRemotes, - makeAgoricNamesRemotesFromFakeStorage, -} from '@agoric/vats/tools/board-utils.js'; -import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; -import type { ExecutionContext } from 'ava'; -import { type SwingsetTestKit, makeSwingsetTestKit } from './supports.js'; -import { - type GovernanceDriver, - type PriceFeedDriver, - type WalletFactoryDriver, - makeGovernanceDriver, - makePriceFeedDriver, - makeWalletFactoryDriver, -} from './drivers.js'; - -export type LiquidationSetup = { - vaults: { - atom: number; - ist: number; - debt: number; - }[]; - bids: ( - | { - give: string; - discount: number; - price?: undefined; - } - | { - give: string; - price: number; - discount?: undefined; - } - )[]; - price: { - starting: number; - trigger: number; - }; - auction: { - start: { - collateral: number; - debt: number; - }; - end: { - collateral: number; - debt: number; - }; - }; -}; - -export const scale6 = x => BigInt(Math.round(x * 1_000_000)); - -const DebtLimitValue = scale6(100_000); - -export const likePayouts = ({ Bid, Collateral }) => ({ - Collateral: { - value: scale6(Collateral), - }, - Bid: { - value: scale6(Bid), - }, -}); - -export const makeLiquidationTestKit = async ({ - swingsetTestKit, - agoricNamesRemotes, - walletFactoryDriver, - governanceDriver, - t, -}: { - swingsetTestKit: SwingsetTestKit; - agoricNamesRemotes: AgoricNamesRemotes; - walletFactoryDriver: WalletFactoryDriver; - governanceDriver: GovernanceDriver; - t: Pick; -}) => { - const priceFeedDrivers = {} as Record; - - console.timeLog('DefaultTestContext', 'priceFeedDriver'); - - console.timeEnd('DefaultTestContext'); - - const setupStartingState = async ({ - collateralBrandKey, - managerIndex, - price, - }: { - collateralBrandKey: string; - managerIndex: number; - price: number; - }) => { - const managerPath = `published.vaultFactory.managers.manager${managerIndex}`; - const { advanceTimeBy, readLatest } = swingsetTestKit; - - await null; - if (!priceFeedDrivers[collateralBrandKey]) { - priceFeedDrivers[collateralBrandKey] = await makePriceFeedDriver( - collateralBrandKey, - agoricNamesRemotes, - walletFactoryDriver, - // TODO read from the config file - [ - 'agoric1krunjcqfrf7la48zrvdfeeqtls5r00ep68mzkr', - 'agoric19uscwxdac6cf6z7d5e26e0jm0lgwstc47cpll8', - 'agoric144rrhh4m09mh7aaffhm6xy223ym76gve2x7y78', - 'agoric19d6gnr9fyp6hev4tlrg87zjrzsd5gzr5qlfq2p', - 'agoric1n4fcxsnkxe4gj6e24naec99hzmc4pjfdccy5nj', - ], - ); - } - - // price feed logic treats zero time as "unset" so advance to nonzero - await advanceTimeBy(1, 'seconds'); - - await priceFeedDrivers[collateralBrandKey].setPrice(price); - - // raise the VaultFactory DebtLimit - await governanceDriver.changeParams( - agoricNamesRemotes.instance.VaultFactory, - { - DebtLimit: { - brand: agoricNamesRemotes.brand.IST, - value: DebtLimitValue, - }, - }, - { - paramPath: { - key: { - collateralBrand: agoricNamesRemotes.brand[collateralBrandKey], - }, - }, - }, - ); - - // raise the PSM MintLimit - await governanceDriver.changeParams( - agoricNamesRemotes.instance['psm-IST-USDC_axl'], - { - MintLimit: { - brand: agoricNamesRemotes.brand.IST, - value: DebtLimitValue, // reuse - }, - }, - ); - - // confirm Relevant Governance Parameter Assumptions - t.like(readLatest(`${managerPath}.governance`), { - current: { - DebtLimit: { value: { value: DebtLimitValue } }, - InterestRate: { - type: 'ratio', - value: { numerator: { value: 1n }, denominator: { value: 100n } }, - }, - LiquidationMargin: { - type: 'ratio', - value: { numerator: { value: 150n }, denominator: { value: 100n } }, - }, - LiquidationPadding: { - type: 'ratio', - value: { numerator: { value: 25n }, denominator: { value: 100n } }, - }, - LiquidationPenalty: { - type: 'ratio', - value: { numerator: { value: 1n }, denominator: { value: 100n } }, - }, - MintFee: { - type: 'ratio', - value: { numerator: { value: 50n }, denominator: { value: 10_000n } }, - }, - }, - }); - t.like(readLatest('published.auction.governance'), { - current: { - AuctionStartDelay: { type: 'relativeTime', value: { relValue: 2n } }, - ClockStep: { - type: 'relativeTime', - value: { relValue: 3n * SECONDS_PER_MINUTE }, - }, - DiscountStep: { type: 'nat', value: 500n }, // 5% - LowestRate: { type: 'nat', value: 6500n }, // 65% - PriceLockPeriod: { - type: 'relativeTime', - value: { relValue: SECONDS_PER_HOUR / 2n }, - }, - StartFrequency: { - type: 'relativeTime', - value: { relValue: SECONDS_PER_HOUR }, - }, - StartingRate: { type: 'nat', value: 10500n }, // 105% - }, - }); - }; - - const check = { - vaultNotification( - managerIndex: number, - vaultIndex: number, - partial: Record, - ) { - const { readLatest } = swingsetTestKit; - - const notification = readLatest( - `published.vaultFactory.managers.manager${managerIndex}.vaults.vault${vaultIndex}`, - ); - t.like(notification, partial); - }, - }; - - const setupVaults = async ( - collateralBrandKey: string, - managerIndex: number, - setup: LiquidationSetup, - base: number = 0, - ) => { - await setupStartingState({ - collateralBrandKey, - managerIndex, - price: setup.price.starting, - }); - - const minter = - await walletFactoryDriver.provideSmartWallet('agoric1minter'); - - for (let i = 0; i < setup.vaults.length; i += 1) { - const offerId = `open-${collateralBrandKey}-vault${base + i}`; - await minter.executeOfferMaker(Offers.vaults.OpenVault, { - offerId, - collateralBrandKey, - wantMinted: setup.vaults[i].ist, - giveCollateral: setup.vaults[i].atom, - }); - t.like(minter.getLatestUpdateRecord(), { - updated: 'offerStatus', - status: { id: offerId, numWantsSatisfied: 1 }, - }); - } - - // Verify starting balances - for (let i = 0; i < setup.vaults.length; i += 1) { - check.vaultNotification(managerIndex, i, { - debtSnapshot: { - debt: { value: scale6(setup.vaults[i].debt) }, - }, - locked: { value: scale6(setup.vaults[i].atom) }, - vaultState: 'active', - }); - } - }; - - const placeBids = async ( - collateralBrandKey: string, - buyerWalletAddress: string, - setup: LiquidationSetup, - base = 0, // number of bids made before - ) => { - const buyer = - await walletFactoryDriver.provideSmartWallet(buyerWalletAddress); - - await buyer.sendOffer( - Offers.psm.swap( - agoricNamesRemotes, - agoricNamesRemotes.instance['psm-IST-USDC_axl'], - { - offerId: `print-${collateralBrandKey}-ist`, - wantMinted: 1_000, - pair: ['IST', 'USDC_axl'], - }, - ), - ); - - const maxBuy = `10000${collateralBrandKey}`; - - for (let i = 0; i < setup.bids.length; i += 1) { - const offerId = `${collateralBrandKey}-bid${i + 1 + base}`; - // bids are long-lasting offers so we can't wait here for completion - await buyer.sendOfferMaker(Offers.auction.Bid, { - offerId, - ...setup.bids[i], - maxBuy, - }); - t.like( - swingsetTestKit.readLatest(`published.wallet.${buyerWalletAddress}`), - { - status: { - id: offerId, - result: 'Your bid has been accepted', - payouts: undefined, - }, - }, - ); - } - }; - - return { - check, - priceFeedDrivers, - setupVaults, - placeBids, - setupStartingState, - }; -}; - -export const makeLiquidationTestContext = async t => { - const swingsetTestKit = await makeSwingsetTestKit(t.log, 'bundles/vaults'); - console.time('DefaultTestContext'); - - const { runUtils, storage } = swingsetTestKit; - console.timeLog('DefaultTestContext', 'swingsetTestKit'); - const { EV } = runUtils; - - // Wait for ATOM to make it into agoricNames - await EV.vat('bootstrap').consumeItem('vaultFactoryKit'); - console.timeLog('DefaultTestContext', 'vaultFactoryKit'); - - // has to be late enough for agoricNames data to have been published - const agoricNamesRemotes: AgoricNamesRemotes = - makeAgoricNamesRemotesFromFakeStorage(storage); - const refreshAgoricNamesRemotes = () => { - Object.assign( - agoricNamesRemotes, - makeAgoricNamesRemotesFromFakeStorage(storage), - ); - }; - agoricNamesRemotes.brand.ATOM || Fail`ATOM missing from agoricNames`; - console.timeLog('DefaultTestContext', 'agoricNamesRemotes'); - - const walletFactoryDriver = await makeWalletFactoryDriver( - runUtils, - storage, - agoricNamesRemotes, - ); - console.timeLog('DefaultTestContext', 'walletFactoryDriver'); - - const governanceDriver = await makeGovernanceDriver( - swingsetTestKit, - agoricNamesRemotes, - walletFactoryDriver, - // TODO read from the config file - [ - 'agoric1ldmtatp24qlllgxmrsjzcpe20fvlkp448zcuce', - 'agoric140dmkrz2e42ergjj7gyvejhzmjzurvqeq82ang', - 'agoric1w8wktaur4zf8qmmtn3n7x3r0jhsjkjntcm3u6h', - ], - ); - console.timeLog('DefaultTestContext', 'governanceDriver'); - - const liquidationTestKit = await makeLiquidationTestKit({ - swingsetTestKit, - agoricNamesRemotes, - walletFactoryDriver, - governanceDriver, - t, - }); - return { - ...swingsetTestKit, - ...liquidationTestKit, - agoricNamesRemotes, - refreshAgoricNamesRemotes, - walletFactoryDriver, - governanceDriver, - }; -}; - -export type LiquidationTestContext = Awaited< - ReturnType ->; - -const addSTARsCollateral = async ( - t: ExecutionContext, -) => { - const { controller, buildProposal } = t.context; - - t.log('building proposal'); - const proposal = await buildProposal( - '@agoric/builders/scripts/inter-protocol/add-STARS.js', - ); - - for await (const bundle of proposal.bundles) { - await controller.validateAndInstallBundle(bundle); - } - t.log('installed', proposal.bundles.length, 'bundles'); - - t.log('launching proposal'); - const bridgeMessage = { - type: 'CORE_EVAL', - evals: proposal.evals, - }; - t.log({ bridgeMessage }); - - const { EV } = t.context.runUtils; - /** @type {ERef} */ - const coreEvalBridgeHandler = await EV.vat('bootstrap').consumeItem( - 'coreEvalBridgeHandler', - ); - await EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); - - t.context.refreshAgoricNamesRemotes(); - - t.log('add-STARS proposal executed'); -}; - -export const ensureVaultCollateral = async ( - collateralBrandKey: string, - t: ExecutionContext, -) => { - // TODO: we'd like to have this work on any brand - const SUPPORTED_BRANDS = ['ATOM', 'STARS']; - - if (!SUPPORTED_BRANDS.includes(collateralBrandKey)) { - throw Error('Unsupported brand type'); - } - - if (collateralBrandKey === 'ATOM') { - return; - } - - if (collateralBrandKey === 'STARS') { - // eslint-disable-next-line @jessie.js/safe-await-separator - await addSTARsCollateral(t); - } -}; From 1f641d9bcdf35161e0ca053742918c29a2f53090 Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Mon, 19 Feb 2024 15:47:02 +0300 Subject: [PATCH 05/12] fix(liquidationVisibility): fix race condition when running tests concurrently --- .../mock-setupChainStorage.js | 378 ++---------------- .../test-liquidationVisibility.js | 11 +- .../test/liquidationVisibility/tools.js | 9 +- 3 files changed, 41 insertions(+), 357 deletions(-) diff --git a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js index 3837af7bd49..b3fecb82af1 100644 --- a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js +++ b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js @@ -16,14 +16,7 @@ import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; import { produceDiagnostics } from '@agoric/vats/src/core/basic-behaviors.js'; import { Far } from '@endo/far'; import { bindAllMethods } from '@agoric/internal/src/method-tools.js'; -import { defaultMarshaller } from '@agoric/internal/src/storage-test-utils.js'; -import { - isStreamCell, - assertPathSegment, - unmarshalFromVstorage, -} from '@agoric/internal/src/lib-chainStorage.js'; -import { heapZone } from '@agoric/zone'; -import * as cb from '@agoric/internal/src/callback.js'; +import { makeMockChainStorageRoot } from '@agoric/internal/src/storage-test-utils.js'; import { installPuppetGovernance, produceInstallations } from '../supports.js'; import { startEconomicCommittee } from '../../src/proposals/startEconCommittee.js'; import { @@ -32,358 +25,33 @@ import { startAuctioneer, } from '../../src/proposals/econ-behaviors.js'; -let blockMakeChildNode = ''; - -export const setBlockMakeChildNode = nodeName => { - blockMakeChildNode = nodeName; - return `LOG: blockMakeChildNode set to node ${nodeName}`; -}; - -/** - * This represents a node in an IAVL tree. - * - * The active implementation is x/vstorage, an Agoric extension of the Cosmos - * SDK. - * - * Vstorage is a hierarchical externally-reachable storage structure that - * identifies children by restricted ASCII name and is associated with arbitrary - * string-valued data for each node, defaulting to the empty string. - * - * @typedef {object} StorageNode - * @property {(data: string) => Promise} setValue publishes some data - * @property {() => string} getPath the chain storage path at which the node was - * constructed - * @property {() => Promise} getStoreKey DEPRECATED use getPath - * @property {( - * subPath: string, - * options?: { sequence?: boolean }, - * ) => StorageNode} makeChildNode - */ - -const ChainStorageNodeI = M.interface('StorageNode', { - setValue: M.callWhen(M.string()).returns(), - getPath: M.call().returns(M.string()), - getStoreKey: M.callWhen().returns(M.record()), - makeChildNode: M.call(M.string()) - .optional(M.splitRecord({}, { sequence: M.boolean() }, {})) - .returns(M.or(M.remotable('StorageNode'), M.promise())), -}); - -/** - * Must match the switch in vstorage.go using `vstorageMessage` type - * - * @typedef {| 'get' - * | 'getStoreKey' - * | 'has' - * | 'children' - * | 'entries' - * | 'values' - * | 'size'} StorageGetByPathMessageMethod - * - * @typedef {'set' | 'setWithoutNotify' | 'append'} StorageUpdateEntriesMessageMethod - * - * @typedef {| StorageGetByPathMessageMethod - * | StorageUpdateEntriesMessageMethod} StorageMessageMethod - * - * @typedef {[path: string]} StorageGetByPathMessageArgs - * - * @typedef {[path: string, value?: string | null]} StorageEntry - * - * @typedef {StorageEntry[]} StorageUpdateEntriesMessageArgs - * - * @typedef {| { - * method: StorageGetByPathMessageMethod; - * args: StorageGetByPathMessageArgs; - * } - * | { - * method: StorageUpdateEntriesMessageMethod; - * args: StorageUpdateEntriesMessageArgs; - * }} StorageMessage - */ - -/** @param {import('@agoric/base-zone').Zone} zone */ -const prepareChainStorageNode = zone => { - /** - * Create a storage node for a given backing storage interface and path. - * - * @param {import('@agoric/internal/src/callback.js').Callback< - * (message: StorageMessage) => any - * >} messenger - * a callback for sending a storageMessage object to the storage - * implementation (cf. golang/cosmos/x/vstorage/vstorage.go) - * @param {string} path - * @param {object} [options] - * @param {boolean} [options.sequence] set values with `append` messages - * rather than `set` messages so the backing implementation employs a - * wrapping structure that preserves each value set within a single block. - * Child nodes default to inheriting this option from their parent. - * @returns {StorageNode} - */ - const makeChainStorageNode = zone.exoClass( - 'ChainStorageNode', - ChainStorageNodeI, - /** - * @param {import('@agoric/internal/src/callback.js').Callback< - * (message: StorageMessage) => any - * >} messenger - * @param {string} path - * @param {object} [options] - * @param {boolean} [options.sequence] - */ - (messenger, path, { sequence = false } = {}) => { - assert.typeof(path, 'string'); - assert.typeof(sequence, 'boolean'); - return harden({ path, messenger, sequence }); - }, - { - getPath() { - return this.state.path; - }, - /** - * @deprecated use getPath - * @type {() => Promise} - */ - async getStoreKey() { - const { path, messenger } = this.state; - return cb.callE(messenger, { - method: 'getStoreKey', - args: [path], - }); - }, - - makeChildNode(name, childNodeOptions = {}) { - if (blockMakeChildNode === name) { - console.log(`Log: MOCK makeChildNode REJECTED for node ${name}`); - setBlockMakeChildNode(''); - return Promise.reject(); - } - - const { sequence, path, messenger } = this.state; - assertPathSegment(name); - const mergedOptions = { sequence, ...childNodeOptions }; - return makeChainStorageNode( - messenger, - `${path}.${name}`, - mergedOptions, - ); - }, - /** @type {(value: string) => Promise} */ - async setValue(value) { - const { sequence, path, messenger } = this.state; - assert.typeof(value, 'string'); - /** @type {StorageEntry} */ - let entry; - if (!sequence && !value) { - entry = [path]; - } else { - entry = [path, value]; - } - await cb.callE(messenger, { - method: sequence ? 'append' : 'set', - args: [entry], - }); - }, - // Possible extensions: - // * getValue() - // * getChildNames() and/or makeChildNodes() - // * getName() - // * recursive delete - // * batch operations - // * local buffering (with end-of-block commit) - }, - ); - return makeChainStorageNode; -}; - -const makeHeapChainStorageNode = prepareChainStorageNode(heapZone); - -/** - * Create a heap-based root storage node for a given backing function and root - * path. - * - * @param {(message: StorageMessage) => any} handleStorageMessage a function for - * sending a storageMessage object to the storage implementation (cf. - * golang/cosmos/x/vstorage/vstorage.go) - * @param {string} rootPath - * @param {object} [rootOptions] - * @param {boolean} [rootOptions.sequence] employ a wrapping structure that - * preserves each value set within a single block, and default child nodes to - * do the same - */ -function makeChainStorageRoot( - handleStorageMessage, - rootPath, - rootOptions = {}, -) { - const messenger = cb.makeFunctionCallback(handleStorageMessage); - - // Use the heapZone directly. - const rootNode = makeHeapChainStorageNode(messenger, rootPath, rootOptions); - return rootNode; -} - -const { Fail } = assert; - -/** - * A map corresponding with a total function such that `get(key)` is assumed to - * always succeed. - * - * @template K, V - * @typedef {{ [k in Exclude, 'get'>]: Map[k] } & { - * get: (key: K) => V; - * }} TotalMap - */ - /** - * For testing, creates a chainStorage root node over an in-memory map and - * exposes both the map and the sequence of received messages. The `sequence` - * option defaults to true. * - * @param {string} rootPath - * @param {Parameters[2]} [rootOptions] + * @param {import('@agoric/notifier').StorageNode} chainStorageP + * @param {Map} childrenMap */ -const makeFakeStorageKit = (rootPath, rootOptions) => { - const trace = makeTracer('StorTU', false); - const resolvedOptions = { sequence: true, ...rootOptions }; - /** @type {TotalMap} */ - const data = new Map(); - /** @param {string} prefix */ - const getChildEntries = prefix => { - assert(prefix.endsWith('.')); - const childEntries = new Map(); - for (const [path, value] of data.entries()) { - if (!path.startsWith(prefix)) { - continue; +export const makeStorageWrapper = async (chainStorageP, childrenMap) => { + let childNodesBlocked = false; + + const chainStorage = await chainStorageP; + return Far('StorageWrapper', { + ...bindAllMethods(chainStorage), + makeChildNode: nodeName => { + if (childNodesBlocked === true) { + return Promise.reject(new Error('Child nodes blocked')); } - const [segment, ...suffix] = path.slice(prefix.length).split('.'); - if (suffix.length === 0) { - childEntries.set(segment, value); - } else if (!childEntries.has(segment)) { - childEntries.set(segment, null); - } - } - return childEntries; - }; - /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageMessage[]} */ - const messages = []; - /** @param {import('@agoric/internal/src/lib-chainStorage.js').StorageMessage} message */ - // eslint-disable-next-line consistent-return - const toStorage = message => { - messages.push(message); - switch (message.method) { - case 'getStoreKey': { - const [key] = message.args; - return { storeName: 'swingset', storeSubkey: `fake:${key}` }; - } - case 'get': { - const [key] = message.args; - return data.has(key) ? data.get(key) : null; - } - case 'children': { - const [key] = message.args; - const childEntries = getChildEntries(`${key}.`); - return [...childEntries.keys()]; - } - case 'entries': { - const [key] = message.args; - const childEntries = getChildEntries(`${key}.`); - return [...childEntries.entries()].map(entry => - entry[1] != null ? entry : [entry[0]], - ); - } - case 'set': - case 'setWithoutNotify': { - trace('toStorage set', message); - /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageEntry[]} */ - const newEntries = message.args; - for (const [key, value] of newEntries) { - if (value != null) { - data.set(key, value); - } else { - data.delete(key); - } - } - break; - } - case 'append': { - trace('toStorage append', message); - /** @type {import('@agoric/internal/src/lib-chainStorage.js').StorageEntry[]} */ - const newEntries = message.args; - for (const [key, value] of newEntries) { - value != null || Fail`attempt to append with no value`; - // In the absence of block boundaries, everything goes in a single StreamCell. - const oldVal = data.get(key); - let streamCell; - if (oldVal != null) { - try { - streamCell = JSON.parse(oldVal); - assert(isStreamCell(streamCell)); - } catch (_err) { - streamCell = undefined; - } - } - if (streamCell === undefined) { - streamCell = { - blockHeight: '0', - values: oldVal != null ? [oldVal] : [], - }; - } - streamCell.values.push(value); - data.set(key, JSON.stringify(streamCell)); - } - break; - } - case 'size': - // Intentionally incorrect because it counts non-child descendants, - // but nevertheless supports a "has children" test. - return [...data.keys()].filter(k => k.startsWith(`${message.args[0]}.`)) - .length; - default: - throw Error(`unsupported method: ${message.method}`); - } - }; - const rootNode = makeChainStorageRoot(toStorage, rootPath, resolvedOptions); - return { - rootNode, - // eslint-disable-next-line object-shorthand - data: /** @type {Map} */ (data), - messages, - toStorage, - }; -}; -harden(makeFakeStorageKit); -/** @typedef {ReturnType} FakeStorageKit */ -const makeMockChainStorageRoot = () => { - const { rootNode, data } = makeFakeStorageKit('mockChainStorageRoot'); - return Far('mockChainStorage', { - ...bindAllMethods(rootNode), - /** - * Defaults to deserializing slot references into plain Remotable objects - * having the specified interface name (as from `Far(iface)`), but can - * accept a different marshaller for producing Remotables that e.g. embed - * the slot string in their iface name. - * - * @param {string} path - * @param {import('@agoric/internal/src/lib-chainStorage.js').Marshaller} marshaller - * @param {number} [index] - * @returns {unknown} - */ - getBody: (path, marshaller = defaultMarshaller, index = -1) => { - data.size || Fail`no data in storage`; - /** - * @type {ReturnType< - * typeof import('@endo/marshal').makeMarshal - * >['fromCapData']} - */ - const fromCapData = (...args) => - Reflect.apply(marshaller.fromCapData, marshaller, args); - return unmarshalFromVstorage(data, path, fromCapData, index); + const child = makeStorageWrapper( + E(chainStorage).makeChildNode(nodeName), + childrenMap, + ); + childrenMap.set(nodeName, child); + + return child; }, - keys: () => [...data.keys()], + toggleChildrenBlocked: () => (childNodesBlocked = !childNodesBlocked), }); }; -/** @typedef {ReturnType} MockChainStorageRoot */ /** * @param {any} t @@ -398,11 +66,14 @@ const setupBootstrap = async (t, optTimer) => { */ (space); await produceDiagnostics(space); + const childrenNodes = new Map(); const timer = optTimer || buildManualTimer(t.log); produce.chainTimerService.resolve(timer); // @ts-expect-error - produce.chainStorage.resolve(makeMockChainStorageRoot()); + produce.chainStorage.resolve( + makeStorageWrapper(makeMockChainStorageRoot(), childrenNodes), + ); produce.board.resolve(makeFakeBoard()); const { zoe, feeMintAccess, run } = t.context; @@ -417,6 +88,7 @@ const setupBootstrap = async (t, optTimer) => { const { brand, issuer } = spaces; brand.produce.IST.resolve(run.brand); issuer.produce.IST.resolve(run.issuer); + produce.childrenNodes.resolve(childrenNodes); return { produce, consume, modules: { utils: { ...utils } }, ...spaces }; }; diff --git a/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js index 74e04ddd5fb..2fb71917052 100644 --- a/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js +++ b/packages/inter-protocol/test/liquidationVisibility/test-liquidationVisibility.js @@ -53,7 +53,6 @@ import { assertVaultNotification, } from './assertions.js'; import { Phase } from '../vaultFactory/driver.js'; -import { setBlockMakeChildNode } from './mock-setupChainStorage.js'; const trace = makeTracer('TestLiquidationVisibility', false); @@ -166,6 +165,7 @@ test('liq-flow-1', async t => { // drop collateral price from 5:1 to 4:1 and liquidate vault aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + await eventLoopIteration(); await assertVaultState(t, vaultNotifier, 'active'); @@ -388,6 +388,7 @@ test('liq-flow-1.1', async t => { // drop collateral price from 5:1 to 4:1 and liquidate vault aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); abtcTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, abtc.brand)); + await eventLoopIteration(); await assertVaultState(t, vaultNotifierAeth, 'active'); await assertVaultState(t, vaultNotifierAbtc, 'active'); @@ -746,6 +747,7 @@ test('liq-flow-2a', async t => { await E(aethTestPriceAuthority).setPrice( makeRatio(70n, run.brand, 10n, aeth.brand), ); + await eventLoopIteration(); trace(t, 'changed price to 7 RUN/Aeth'); // A bidder places a bid @@ -1265,7 +1267,7 @@ test('liq-rejected-schedule', async t => { // drop collateral price from 5:1 to 4:1 and liquidate vault aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); - + await eventLoopIteration(); await assertVaultState(t, vaultNotifier, 'active'); await E(auctioneerKit.publicFacet).setRejectGetSchedules(true); @@ -1390,6 +1392,7 @@ test('liq-rejected-timestampStorageNode', async t => { reserveKit: { reserveCreatorFacet, reservePublicFacet }, auctioneerKit, chainStorage, + childrenNodes, } = services; const { reserveTracker } = await getMetricTrackers({ @@ -1428,10 +1431,12 @@ test('liq-rejected-timestampStorageNode', async t => { ); t.is(vstorageBeforeLiquidation.length, 0); - setBlockMakeChildNode('3600'); + const liquiationNode = await childrenNodes.get('liquidations'); + liquiationNode.toggleChildrenBlocked(); // drop collateral price from 5:1 to 4:1 and liquidate vault aethTestPriceAuthority.setPrice(makeRatio(40n, run.brand, 10n, aeth.brand)); + await eventLoopIteration(); const { startTime } = await startAuctionClock(auctioneerKit, manualTimer); diff --git a/packages/inter-protocol/test/liquidationVisibility/tools.js b/packages/inter-protocol/test/liquidationVisibility/tools.js index 7ac97e98602..72aac4b9e1f 100644 --- a/packages/inter-protocol/test/liquidationVisibility/tools.js +++ b/packages/inter-protocol/test/liquidationVisibility/tools.js @@ -1,7 +1,11 @@ import { E } from '@endo/eventual-send'; import { makeIssuerKit } from '@agoric/ertp'; import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; -import { allValues, makeTracer, objectMap } from '@agoric/internal'; +import { + allValues, + makeTracer, + objectMap, +} from '@agoric/internal'; import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; import { makeRatio, @@ -206,6 +210,7 @@ export const setupServices = async ( abtcCollateralManager, chainStorage, board, + childrenNodes, ] = await Promise.all([ E(consume.agoricNames).lookup('instance', 'VaultFactoryGovernor'), vaultFactoryCreatorFacetP, @@ -220,6 +225,7 @@ export const setupServices = async ( : Promise.resolve(undefined), consume.chainStorage, consume.board, + consume.childrenNodes, ]); trace(t, 'pa', { governorInstance, @@ -264,6 +270,7 @@ export const setupServices = async ( abtcTestPriceAuthority, chainStorage, board, + childrenNodes, }; }; From a11a8307a9387a9d12b4063b1ef02c1c52d28d42 Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Mon, 19 Feb 2024 22:45:47 +0300 Subject: [PATCH 06/12] fix(liquidationVisibility): lint fixes --- .../inter-protocol/src/vaultFactory/vaultManager.js | 1 - .../liquidationVisibility/mock-setupChainStorage.js | 1 - .../inter-protocol/test/liquidationVisibility/tools.js | 10 ++++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index 2f8bc8a4f37..c648349b081 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -694,7 +694,6 @@ export const prepareVaultManagerKit = ( collateralSold: plan.collateralSold, collateralRemaining: plan.collatRemaining, // @ts-expect-error - // eslint-disable-next-line @endo/no-optional-chaining endTime: auctionSchedule?.liveAuctionSchedule.endTime, }; return E( diff --git a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js index b3fecb82af1..cf7517fbad5 100644 --- a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js +++ b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js @@ -1,6 +1,5 @@ /* eslint-disable import/no-extraneous-dependencies */ import { E } from '@endo/eventual-send'; -import { M } from '@endo/patterns'; import { makeIssuerKit, AssetKind } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; diff --git a/packages/inter-protocol/test/liquidationVisibility/tools.js b/packages/inter-protocol/test/liquidationVisibility/tools.js index 72aac4b9e1f..1d1f291a325 100644 --- a/packages/inter-protocol/test/liquidationVisibility/tools.js +++ b/packages/inter-protocol/test/liquidationVisibility/tools.js @@ -1,11 +1,7 @@ import { E } from '@endo/eventual-send'; import { makeIssuerKit } from '@agoric/ertp'; import { unsafeMakeBundleCache } from '@agoric/swingset-vat/tools/bundleTool.js'; -import { - allValues, - makeTracer, - objectMap, -} from '@agoric/internal'; +import { allValues, makeTracer, objectMap } from '@agoric/internal'; import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; import { makeRatio, @@ -92,7 +88,7 @@ export const setupBasics = async (zoe, contractsWrapper) => { * @param {import('@agoric/time').TimerService} timer * @param {RelativeTime} quoteInterval * @param {Partial} [auctionParams] - * @param setupExtraAsset + * @param {boolean} setupExtraAsset */ export const setupServices = async ( t, @@ -283,7 +279,9 @@ export const setClockAndAdvanceNTimes = async ( let currentTime = start; // first time through is at START, then n TIMES more plus INCR for (let i = 0; i <= times; i += 1) { + // eslint-disable-next-line no-await-in-loop await timer.advanceTo(TimeMath.absValue(currentTime)); + // eslint-disable-next-line no-await-in-loop await eventLoopIteration(); currentTime = TimeMath.addAbsRel(currentTime, TimeMath.relValue(incr)); } From 743607208c7eb7ea04cc9603995047cbab79ebc9 Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Mon, 19 Feb 2024 23:20:35 +0300 Subject: [PATCH 07/12] fix(liquidationVisibility): lint and type fixes --- packages/inter-protocol/src/vaultFactory/liquidation.js | 7 ------- packages/inter-protocol/src/vaultFactory/types.js | 2 +- packages/inter-protocol/src/vaultFactory/vaultManager.js | 2 +- .../liquidationVisibility/auctioneer-contract-wrapper.js | 6 +++--- .../test/liquidationVisibility/mock-setupChainStorage.js | 9 ++++++--- .../inter-protocol/test/liquidationVisibility/tools.js | 4 +++- 6 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/inter-protocol/src/vaultFactory/liquidation.js b/packages/inter-protocol/src/vaultFactory/liquidation.js index 7b3c9205fc0..a2c1bc6f79b 100644 --- a/packages/inter-protocol/src/vaultFactory/liquidation.js +++ b/packages/inter-protocol/src/vaultFactory/liquidation.js @@ -18,13 +18,6 @@ const trace = makeTracer('LIQ'); /** @typedef {import('@agoric/time/src/types').CancelToken} CancelToken */ /** @typedef {import('@agoric/time/src/types').RelativeTimeRecord} RelativeTimeRecord */ -/** - * @typedef {MapStore< - * Vault, - * { collateralAmount: Amount<'nat'>; debtAmount: Amount<'nat'> } - * >} VaultData - */ - /** * @typedef {MapStore< * Vault, diff --git a/packages/inter-protocol/src/vaultFactory/types.js b/packages/inter-protocol/src/vaultFactory/types.js index eef37512d8e..c78f9cc80c0 100644 --- a/packages/inter-protocol/src/vaultFactory/types.js +++ b/packages/inter-protocol/src/vaultFactory/types.js @@ -13,7 +13,7 @@ * * @typedef {import('@agoric/time').Timestamp} Timestamp * - * @typedef {import('@agoric/time').TimestampRecord} TimestampRecord + * @typedef {import('@agoric/time/src/types.js').TimestampRecord} TimestampRecord * * @typedef {import('@agoric/time').RelativeTime} RelativeTime */ diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index c648349b081..ff8a6d48bfe 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -181,7 +181,7 @@ const trace = makeTracer('VM'); * mintedProceeds?: Amount<'nat'>; * collateralSold?: Amount<'nat'>; * collateralRemaining?: Amount<'nat'>; - * endTime?: import('@agoric/time').TimestampRecord | null; + * endTime?: import('@agoric/time/src/types.js').TimestampRecord | null; * }} AuctionResultState * * @typedef {{ diff --git a/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js b/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js index f7adb16557b..4a1a7a37e63 100644 --- a/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js +++ b/packages/inter-protocol/test/liquidationVisibility/auctioneer-contract-wrapper.js @@ -101,7 +101,7 @@ const distributeProportionalShares = ( const collShare = makeRatioFromAmounts(unsoldCollateral, totalCollDeposited); const currShare = makeRatioFromAmounts(proceeds, totalCollDeposited); - /** @type {TransferPart[]} */ + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ const transfers = []; let proceedsLeft = proceeds; let collateralLeft = unsoldCollateral; @@ -261,7 +261,7 @@ export const distributeProportionalSharesWithLimits = ( // collateral to reach their share. Then see what's left, and allocate it // among the remaining depositors. Escape to distributeProportionalShares if // anything doesn't work. - /** @type {TransferPart[]} */ + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ const transfers = []; let proceedsLeft = proceeds; let collateralLeft = unsoldCollateral; @@ -386,7 +386,7 @@ export const distributeProportionalSharesWithLimits = ( /** * @param {ZCF< * GovernanceTerms & { - * timerService: import('@agoric/time').TimerService; + * timerService: import('@agoric/time/src/types.js').TimerService; * reservePublicFacet: AssetReservePublicFacet; * priceAuthority: PriceAuthority; * } diff --git a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js index cf7517fbad5..13f822387da 100644 --- a/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js +++ b/packages/inter-protocol/test/liquidationVisibility/mock-setupChainStorage.js @@ -1,5 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import { E } from '@endo/eventual-send'; +import '@agoric/notifier/exported.js'; +import '@agoric/time'; import { makeIssuerKit, AssetKind } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; import { buildManualTimer } from '@agoric/swingset-vat/tools/manual-timer.js'; @@ -26,7 +28,7 @@ import { /** * - * @param {import('@agoric/notifier').StorageNode} chainStorageP + * @param {ERef} chainStorageP * @param {Map} childrenMap */ export const makeStorageWrapper = async (chainStorageP, childrenMap) => { @@ -54,7 +56,7 @@ export const makeStorageWrapper = async (chainStorageP, childrenMap) => { /** * @param {any} t - * @param {import('@agoric/time').TimerService} [optTimer] + * @param {import('@agoric/time/src/types.js').TimerService} [optTimer] */ const setupBootstrap = async (t, optTimer) => { const trace = makeTracer('PromiseSpace', false); @@ -69,8 +71,8 @@ const setupBootstrap = async (t, optTimer) => { const timer = optTimer || buildManualTimer(t.log); produce.chainTimerService.resolve(timer); - // @ts-expect-error produce.chainStorage.resolve( + // @ts-expect-error makeStorageWrapper(makeMockChainStorageRoot(), childrenNodes), ); produce.board.resolve(makeFakeBoard()); @@ -87,6 +89,7 @@ const setupBootstrap = async (t, optTimer) => { const { brand, issuer } = spaces; brand.produce.IST.resolve(run.brand); issuer.produce.IST.resolve(run.issuer); + // @ts-expect-error produce.childrenNodes.resolve(childrenNodes); return { produce, consume, modules: { utils: { ...utils } }, ...spaces }; diff --git a/packages/inter-protocol/test/liquidationVisibility/tools.js b/packages/inter-protocol/test/liquidationVisibility/tools.js index 1d1f291a325..079795522a7 100644 --- a/packages/inter-protocol/test/liquidationVisibility/tools.js +++ b/packages/inter-protocol/test/liquidationVisibility/tools.js @@ -85,7 +85,7 @@ export const setupBasics = async (zoe, contractsWrapper) => { * @param {import('ava').ExecutionContext} t * @param {NatValue[] | Ratio} priceOrList * @param {Amount | undefined} unitAmountIn - * @param {import('@agoric/time').TimerService} timer + * @param {import('@agoric/time/src/types.js').TimerService} timer * @param {RelativeTime} quoteInterval * @param {Partial} [auctionParams] * @param {boolean} setupExtraAsset @@ -192,6 +192,7 @@ export const setupServices = async ( * CollateralManager | undefined, * chainStorage, * board, + * childrenNodes, * ]} */ const [ @@ -221,6 +222,7 @@ export const setupServices = async ( : Promise.resolve(undefined), consume.chainStorage, consume.board, + // @ts-expect-error consume.childrenNodes, ]); trace(t, 'pa', { From 312ae8e8398c360e869482d4b99c0dc96c518ccb Mon Sep 17 00:00:00 2001 From: anilhelvaci Date: Tue, 20 Feb 2024 18:18:08 +0300 Subject: [PATCH 08/12] chore(liquidationVisibility): prepare upgrade proposal --- .../scripts/liquidation-visibility-upgrade.js | 40 +++++++++++++++++++ .../proposals/vaultsLiquidationVisibility.js | 36 +++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/inter-protocol/scripts/liquidation-visibility-upgrade.js create mode 100644 packages/inter-protocol/src/proposals/vaultsLiquidationVisibility.js diff --git a/packages/inter-protocol/scripts/liquidation-visibility-upgrade.js b/packages/inter-protocol/scripts/liquidation-visibility-upgrade.js new file mode 100644 index 00000000000..66cffb589e1 --- /dev/null +++ b/packages/inter-protocol/scripts/liquidation-visibility-upgrade.js @@ -0,0 +1,40 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; +import { makeInstallCache } from '../src/proposals/utils.js'; +import { getManifestVaultsUpgrade } from '../src/proposals/vaultsLiquidationVisibility.js'; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ +export const vaultsUpgradeProposalBuilder = async ({ + publishRef, + install: install0, + wrapInstall, +}) => { + const install = wrapInstall ? wrapInstall(install0) : install0; + + return harden({ + sourceSpec: '../src/proposals/vaultsLiquidationVisibility.js', + getManifestCall: [ + getManifestVaultsUpgrade.name, + { + vaultFactoryRef: publishRef( + install( + '../src/vaultFactory/vaultFactory.js', + '../bundles/bundle-vaultFactory.js', + ), + ), + }, + ], + }); +}; + +export default async (homeP, endowments) => { + const { writeCoreProposal } = await makeHelpers(homeP, endowments); + + const tool = await makeInstallCache(homeP, { + loadBundle: spec => import(spec), + }); + + await writeCoreProposal('upgrade-vaults-liq-visibility', opts => + // @ts-expect-error XXX makeInstallCache types + vaultsUpgradeProposalBuilder({ ...opts, wrapInstall: tool.wrapInstall }), + ); +}; diff --git a/packages/inter-protocol/src/proposals/vaultsLiquidationVisibility.js b/packages/inter-protocol/src/proposals/vaultsLiquidationVisibility.js new file mode 100644 index 00000000000..c7ffcc5966e --- /dev/null +++ b/packages/inter-protocol/src/proposals/vaultsLiquidationVisibility.js @@ -0,0 +1,36 @@ +import { E } from '@endo/far'; + +export const upgradeVaultsLiquidationVisibility = async ( + powers, + { options: { vaultFactoryRef } }, +) => { + const { + consume: { vaultFactoryKit: vaultFactoryKitP }, + } = powers; + + const { adminFacet, privateArgs } = await vaultFactoryKitP; + const result = await E(adminFacet).upgradeContract( + vaultFactoryRef.bundleID, + privateArgs, + ); + + console.log('Upgrade Resulted With: ', result); +}; + +/** @type { import("@agoric/vats/src/core/lib-boot").BootstrapManifest } */ +const manifest = { + [upgradeVaultsLiquidationVisibility.name]: { + // include rationale for closely-held, high authority capabilities + consume: { + vaultFactoryKit: `to upgrade vaultFactory using its adminFacet`, + }, + }, +}; +harden(manifest); + +export const getManifestVaultsUpgrade = (_powers, { vaultFactoryRef }) => { + return harden({ + manifest, + options: { vaultFactoryRef }, + }); +}; From ac24b5eada60732ed815003a964fd8af6e51b614 Mon Sep 17 00:00:00 2001 From: JorgeLopes-BytePitch Date: Thu, 22 Feb 2024 10:22:20 +0000 Subject: [PATCH 09/12] chore(liquidationVisibility): copy old version of vaultFactory and update dependencies --- packages/vats/package.json | 1 + .../vaultFactory/AttackersGuide.md | 76 + .../vaultFactory/Liquidation.md | 100 ++ .../vaultFactory/burn.js | 17 + .../vaultFactory/liquidation.js | 296 ++++ .../vaultFactory/math.js | 95 ++ .../vaultFactory/orderedVaultStore.js | 69 + .../vaultFactory/params.js | 217 +++ .../vaultFactory/prioritizedVaults.js | 151 ++ .../vaultFactory/proceeds.js | 274 ++++ .../vaultFactory/storeUtils.js | 171 +++ .../vaultFactory/type-imports.js | 4 + .../vaultFactory/types.js | 134 ++ .../vaultFactory/vault.js | 871 ++++++++++++ .../vaultFactory/vaultDirector.js | 537 +++++++ .../vaultFactory/vaultFactory.js | 149 ++ .../vaultFactory/vaultHolder.js | 129 ++ .../vaultFactory/vaultKit.js | 46 + .../vaultFactory/vaultManager.js | 1232 +++++++++++++++++ 19 files changed, 4569 insertions(+) create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/AttackersGuide.md create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/Liquidation.md create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/burn.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/liquidation.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/math.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/orderedVaultStore.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/params.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/prioritizedVaults.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/proceeds.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/storeUtils.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/type-imports.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/types.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vault.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultDirector.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultFactory.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultHolder.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultKit.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultManager.js diff --git a/packages/vats/package.json b/packages/vats/package.json index dd4ab68d941..40f4b505bc8 100644 --- a/packages/vats/package.json +++ b/packages/vats/package.json @@ -49,6 +49,7 @@ "@endo/nat": "4.1.27", "@endo/patterns": "^0.2.2", "@endo/promise-kit": "0.2.56", + "@endo/eventual-send": "1.1.1", "import-meta-resolve": "^2.2.1", "jessie.js": "^0.3.2" }, diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/AttackersGuide.md b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/AttackersGuide.md new file mode 100644 index 00000000000..fdbdaf83e35 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/AttackersGuide.md @@ -0,0 +1,76 @@ +# An Attacker's guide to the Vault Factory + +This is an incomplete list of potential weak points that an attacker might want to focus +on when looking for ways to violate the integrity of the Vault Factory. It's here to help +defenders, as "attacker's mindset" is a good way to focus attention for the defender. The +list should correspond pretty closely to the set of assurances that the Vault Factory aims +to support. + +The VaultFactory's purpose is to make it possible for users to deposit valuable tokens and +borrow `Minted` in exchange. They will pay interest on the total outstanding balance, and have +to pay off the debt or deposit additional collateral to ensure that the ratio of debt to +collateral stays above a threshold. When the VaultManager detects that the ratio has +fallen below the limit, their collateral will be sold. + +Part of the purpose of the Vault mechanism is to link the price of `Minted` to +dollar-equivalents. We're currently getting prices from a configured PriceAuthority in +RUN, but this should (https://github.com/Agoric/agoric-sdk/issues/4714) be changed to be +priced in US Dollars. Tying the price at which Vaults lend `Minted` to the dollar price of the +collateral is intended to set a ceiling on what one might pay for `Minted`. The fact that +vaults get liquidated to the AMM if the dollar-value of the collateral falls below the +required ratio should restore the value of `Minted` when collateral values fall. In DAI, the +same linkage is enforced by auctioning off the collateral. Does selling into the AMM +achieve the same end? + +## VaultManager + +Collateral from all borrowers who have deposited the same currency is intermingled. If we +don't keep good records or can be confused about how much belongs to whom, there could be +unintended interactions between the vaults held by a single VaultManager on behalf of +different users. + +The VaultManagers calculate interest once daily, and record the change centrally without +updating individual vaults. Vault owners must not be able to adjust balances or close +their vault without being impacted by their up-to-date debt. + +## Fees + +Fees are charged when Vaults are opened, and as interest is charged. That money is +available to be collected by the creator of the VaultFactory. Is the fee collection +interface guarded carefully enough? Can any unintended party collect the fees? + +## `Minted` Mint + +The VaultManager has access to the `Minted` mint, and can create new `Minted` freely. This power is +shared with every Vault, so if they can be suborned, arbitrary `Minted` can be created. Is this +power sufficiently constrained? + +## Vaults + +When the user repays their debt, they can withdraw collateral. Actually, they can withdraw +collateral to the extent that it isn't required by the current loan. Can they ever +withdraw more than that, and leave their loan under-collateralized? + +If prices are falling and the trader can see that more clearly than the Vaults do, could +they withdraw liquidity before the Vaults realize that the price has changed? We +understand this is a distributed, asynchronous system, so to some extent, this is +inevitable. Are the Vaults more susceptible to this than is justifiable? + +Vaults are stored in an orderedStore so that they can be efficiently liquidated in the +order from least to most collateralized. Are there any edge cases that would cause Vaults +to not be processed in the right order? Could a Vault escape the queue and evade +liquidation? + +## Burn + +We burn amounts from liquidation and from paying off debts. Do we always get the amounts +right? Do we burn everywhere we should? Reallocate ensures that rights are conserved, so +no `Minted` is created or destroyed in reallocation. If we burn the wrong amounts, someone will +end up with too much or too little. + +## Liquidation + +The liquidation mechanism is subject to governance. Is the current strategy legible +(i.e. can Vault users tell how the liquidation will be carried out?) Is the liquidation +approach destabilizing? + diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/Liquidation.md b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/Liquidation.md new file mode 100644 index 00000000000..2a353b09f0d --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/Liquidation.md @@ -0,0 +1,100 @@ +The liquidation approach relies on a descending clock auction (a +"dutch" auction). The auction runs at a rate controlled by governed +parameters. Every auction period, each vaultManager checks to see which +vaults are under-water compared to an oracle price captured before the +auction start. The collateral from all those illiquid vaults are sent to the +auction, which attempts to sell them for enough to cover their outstanding +debt at a discount to their value. Since they are over-collateralized this +should normally cover the debt and possibly return excess collateral. When +that is the case (flow 1, below) , we repay the debt, charge a liquidation fee, +and return excess collateral to the vault holders. + +There are two interesting exception cases. All the collateral might be sold +without covering the debt (flow 2a), or there might not be enough bids to +purchase all the collateral (flow 2b). In the first case, vault-holders don't +get anything back, we burn the currency we received to cover a portion of the +debt, and we report a shortfall. + +In the second case, we can't tell whether it's due to a downturn in the +market or a DoS attack on the exchange. In hopes that it's not the latter, we +reinstate as many vaults as can be made whole, declare the rest liquidated, +and expect the next liquidation opportunity either to find the price has +recovered, or to send a possibly different set of vaults to the next auction. + + +**Flow 1: Auction raises enough IST to cover debt** + +The following steps occur in this order + +1. IST raised by the auction is burned to reduce debt in 1:1 ratio. + +- Definitionally, this should result in zero debt. Since the auction should stop + when it raises enough IST, it should result in zero IST remaining as well. + However, if some excess IST exists, it should be transferred to the reserve. + +2. From any remaining collateral, the liquidation penalty is taken and + transferred to the reserve. + +- Liquidation penalty is calculated as debt / current oracle price at auction + start * liquidation penalty + +3. Excess collateral - if any - is returned to vault holders + +- Vault holders receive collateral back sequenced by highest collateralization + ratio at liquidation time first. +- The max amount of collateral a vault should be able to receive back is: + original collateral - collateral covering their share of debt (using average + liquidation price) - collateral covering their share of the penalty (their + debt / total debt \* total penalty) + +**Flow 2: Auction does not raise enough to cover IST debt** + +This flow further bifurcates based on whether the auction has sold all its +collateral asset and still has not covered the debt or has collateral +remaining (which simply did not receive bidders) + +**Flow 2a: all collateral sold and debt is not covered** + +1. IST raised by the auction is burned to reduce debt in 1:1 ratio. + +- Definitionally, this should result in zero IST remaining and some debt + remaining. + +2. Remaining debt is recorded in the reserve as a shortfall + + *sequence ends; no penalty is taken and vaults receive nothing back* + +**Flow 2b: collateral remains but debt is still not covered by IST raised by +auction end** + +1. IST raised by the auction is burned to reduce debt in 1:1 ratio. + +- Definitionally, this should result in zero IST remaining and some debt remaining. + +2. From any remaining collateral, the liquidation penalty is taken and + transferred to the reserve. + +- Liquidation penalty is calculated as debt / current oracle price at auction + start \* liquidation penalty. + + _Note: there now should be debt remaining and possibly collateral remaining_ + +3. The vault manager now iterates through the vaults that were liquidated and + attempts to reconstitute them (minus collateral from the liquidation penalty) + starting from the best CR to worst. + +- Reconstitution means full prior debt AND full prior collateral minus + collateral used from penalty. +- Collateral used for penalty = vault debt / total debt \* total liquidation penalty. +- Debt that is given back to a vault should be subtracted from the vault + manager's view of remaining liquidation debt (i.e., it shouldn't be + double counted) +- Reconstituted vaults are set to OPEN status (i.e., they are live again + and able to be interacted with) + +4. When the vault manager reaches a vault it cannot fully reconstitute + (both full debt and collateral as described above), it marks that vault as + liquidated. It then marks all other lower CR vaults as liquidated. +5. Any remaining collateral is transferred to the reserve. +6. Any remaining debt (subtracting debt that was given back to reconstituted + vaults, as described above) is transferred to the reserve as shortfall. diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/burn.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/burn.js new file mode 100644 index 00000000000..9cfc83b7f7a --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/burn.js @@ -0,0 +1,17 @@ +// @jessie-check + +import '@agoric/zoe/exported.js'; + +import { E } from '@endo/eventual-send'; + +/** + * @param {ZCF} zcf + * @param {ZCFMint} zcfMint + * @param {Amount} amount + */ +export const paymentFromZCFMint = async (zcf, zcfMint, amount) => { + const { zcfSeat, userSeat } = zcf.makeEmptySeatKit(); + zcfMint.mintGains(harden({ Temp: amount }), zcfSeat); + zcfSeat.exit(); + return E(userSeat).getPayout('Temp'); +}; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/liquidation.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/liquidation.js new file mode 100644 index 00000000000..750a3eb4983 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/liquidation.js @@ -0,0 +1,296 @@ +// @jessie-check + +import { AmountMath } from '@agoric/ertp'; +import { makeTracer } from '@agoric/internal'; +import { observeIteration, subscribeEach } from '@agoric/notifier'; +import { makeScalarMapStore } from '@agoric/store'; +import { TimeMath } from '@agoric/time'; +import { atomicRearrange } from '@agoric/zoe/src/contractSupport/index.js'; +import { E } from '@endo/eventual-send'; + +import { AUCTION_START_DELAY, PRICE_LOCK_PERIOD } from '@agoric/inter-protocol/src/auction/params.js'; +import { makeCancelTokenMaker } from '@agoric/inter-protocol/src/auction/util.js'; + +const trace = makeTracer('LIQ'); + +/** @typedef {import('@agoric/time/src/types').TimerService} TimerService */ +/** @typedef {import('@agoric/time/src/types').TimerWaker} TimerWaker */ +/** @typedef {import('@agoric/time/src/types').CancelToken} CancelToken */ +/** @typedef {import('@agoric/time/src/types').RelativeTimeRecord} RelativeTimeRecord */ + +const makeCancelToken = makeCancelTokenMaker('liq'); + +/** + * This will normally be set. If the schedule goes sideways, we'll unschedule + * all events and unset it. When auction params are changed, we'll restart the schedule + * + * @type {object | undefined} + */ +let cancelToken = makeCancelToken(); + +const waitForRepair = () => { + // the params observer will setWakeup to resume. + console.warn( + '🛠️ No wake for reschedule. Repair by resetting auction params.', + ); +}; + +// Generally, canceling wakups has to be followed by setting up new ones but +// not in the case of pausing wakeups. We do that in the event of invalid +// schedule params. When they are repaired by governance, the wakers resume. +/** @param {ERef} timer */ +const cancelWakeups = timer => { + if (cancelToken) { + void E(timer).cancel(cancelToken); + } + cancelToken = undefined; +}; + +/** + * Schedule wakeups for the *next* auction round. + * + * In practice, there are these cases to handle (with N as "live" and N+1 is "next"): + * + * | when (now within the range) | what | + * | -------------------------------- | --------------------------------------- | + * | [start N, nominalStart N+1] | good: schedule normally the three wakers| + * | (nominalStart N+1, endTime N+1] | recover: skip round N+1 and schedule N+2| + * | (endTime N+1, ∞) | give up: wait for repair by governance | + * + * @param {object} opts + * @param {ERef} opts.timer + * @param {TimerWaker} opts.priceLockWaker + * @param {TimerWaker} opts.liquidationWaker + * @param {TimerWaker} opts.reschedulerWaker + * @param {import('@agoric/inter-protocol/src/auction/scheduler.js').Schedule} opts.nextAuctionSchedule + * @param {import('@agoric/time/src/types').TimestampRecord} opts.now + * @param {ParamStateRecord} opts.params + * @returns {void} + */ +const setWakeups = ({ + nextAuctionSchedule, + now, + timer, + reschedulerWaker, + liquidationWaker, + priceLockWaker, + params, +}) => { + const { startTime, endTime } = nextAuctionSchedule; + + /** @type {RelativeTimeRecord} */ + // @ts-expect-error Casting + // eslint-disable-next-line no-restricted-syntax -- https://github.com/Agoric/eslint-config-jessie/issues/33 + const priceLockPeriod = params[PRICE_LOCK_PERIOD].value; + /** @type {RelativeTimeRecord} */ + // @ts-expect-error Casting + // eslint-disable-next-line no-restricted-syntax -- https://github.com/Agoric/eslint-config-jessie/issues/33 + const auctionStartDelay = params[AUCTION_START_DELAY].value; + + // nominal is the declared start time, but the actual auctioning begins after auctionStartDelay + const nominalStart = TimeMath.subtractAbsRel(startTime, auctionStartDelay); + + // Is there a problem (case 2 or 3)? + // setWakeupsForNextAuction is supposed to be called just after an auction + // started. If we're late but there's still time for the nominal start, then + // we'll proceed. Reschedule for the next round if that's still in the future. + // Otherwise, wait for governance params to change. + if (TimeMath.compareAbs(now, nominalStart) > 0) { + // nominalStart is past, so cancel timers and plan to recover + cancelWakeups(timer); + + if (TimeMath.compareAbs(now, endTime) < 0) { + // We're currently scheduling the N+1 (where N is live), but we need to skip it. + // So wake up for the N+2 round's startTime + const afterNextStartTime = TimeMath.addAbsRel(endTime, 1n); + trace( + 'CASE 2: endTime is in the future or now so reschedule waking to startTime of the following round', + afterNextStartTime, + ); + void E(timer).setWakeup(afterNextStartTime, reschedulerWaker); + } else { + trace('CASE 3: endTime is past; wait for repair'); + waitForRepair(); + } + + return; + } + trace('CASE 1: nominalStart is now or in the future'); + + cancelToken = cancelToken || makeCancelToken(); + const priceLockWakeTime = TimeMath.subtractAbsRel( + nominalStart, + priceLockPeriod, + ); + const a = t => TimeMath.absValue(t); + trace('scheduling ', a(priceLockWakeTime), a(nominalStart), a(startTime)); + void E(timer).setWakeup(priceLockWakeTime, priceLockWaker, cancelToken); + void E(timer).setWakeup(nominalStart, liquidationWaker, cancelToken); + // Call setWakeupsForNextAuction again one tick after nominalStart + const afterStart = TimeMath.addAbsRel(startTime, 1n); + void E(timer).setWakeup(afterStart, reschedulerWaker, cancelToken); +}; + +/** + * Schedule wakeups for the *next* auction round. + * + * Called by vaultDirector's resetWakeupsForNextAuction at start() and every + * time there's a "reschedule" wakeup. + * + * @param {ERef} auctioneerPublicFacet + * @param {ERef} timer + * @param {TimerWaker} priceLockWaker + * @param {TimerWaker} liquidationWaker + * @param {TimerWaker} reschedulerWaker + * @returns {Promise} + */ +export const setWakeupsForNextAuction = async ( + auctioneerPublicFacet, + timer, + priceLockWaker, + liquidationWaker, + reschedulerWaker, +) => { + const [{ nextAuctionSchedule }, params, now] = await Promise.all([ + E(auctioneerPublicFacet).getSchedules(), + E(auctioneerPublicFacet).getGovernedParams(), + E(timer).getCurrentTimestamp(), + ]); + + trace( + 'setWakeupsForNextAuction at', + now.absValue, + 'with', + nextAuctionSchedule, + ); + if (!nextAuctionSchedule) { + // There should always be a nextAuctionSchedule. If there isn't, give up for now. + cancelWakeups(timer); + waitForRepair(); + return; + } + + setWakeups({ + nextAuctionSchedule, + now, + timer, + reschedulerWaker, + liquidationWaker, + priceLockWaker, + params, + }); +}; +harden(setWakeupsForNextAuction); + +/** + * @param {Amount<'nat'>} debt + * @param {Amount<'nat'>} minted + * @returns {{ overage: Amount<'nat'>, shortfall: Amount<'nat'>}} + */ +export const liquidationResults = (debt, minted) => { + if (AmountMath.isEmpty(minted)) { + return { overage: minted, shortfall: debt }; + } + + const [overage, shortfall] = AmountMath.isGTE(debt, minted) + ? [AmountMath.makeEmptyFromAmount(debt), AmountMath.subtract(debt, minted)] + : [AmountMath.subtract(minted, debt), AmountMath.makeEmptyFromAmount(debt)]; + + return { overage, shortfall }; +}; +harden(liquidationResults); + +/** + * Watch governed params for change + * + * @param {ERef} auctioneerPublicFacet + * @param {ERef} timer + * @param {TimerWaker} reschedulerWaker + * @returns {void} + */ +export const watchForGovernanceChange = ( + auctioneerPublicFacet, + timer, + reschedulerWaker, +) => { + void E.when(E(timer).getCurrentTimestamp(), now => + // make one observer that will usually ignore the update. + observeIteration( + subscribeEach(E(auctioneerPublicFacet).getSubscription()), + harden({ + async updateState(_newState) { + if (!cancelToken) { + cancelToken = makeCancelToken(); + void E(timer).setWakeup( + // bump one tick to prevent an infinite loop + TimeMath.addAbsRel(now, 1n), + reschedulerWaker, + cancelToken, + ); + } + }, + }), + ), + ); +}; + +/** + * @param {ZCF} zcf + * @param {object} collateralizationDetails + * @param {PriceQuote} collateralizationDetails.quote + * @param {Ratio} collateralizationDetails.interest + * @param {Ratio} collateralizationDetails.margin + * @param {ReturnType} prioritizedVaults + * @param {SetStore} liquidatingVaults + * @param {Brand<'nat'>} debtBrand + * @param {Brand<'nat'>} collateralBrand + * @returns {{ + * vaultData: MapStore, debtAmount: Amount<'nat'>}>, + * totalDebt: Amount<'nat'>, + * totalCollateral: Amount<'nat'>, + * liqSeat: ZCFSeat}} + */ +export const getLiquidatableVaults = ( + zcf, + collateralizationDetails, + prioritizedVaults, + liquidatingVaults, + debtBrand, + collateralBrand, +) => { + const vaultsToLiquidate = prioritizedVaults.removeVaultsBelow( + collateralizationDetails, + ); + /** @type {MapStore, debtAmount: Amount<'nat'>}>} */ + const vaultData = makeScalarMapStore(); + + const { zcfSeat: liqSeat } = zcf.makeEmptySeatKit(); + let totalDebt = AmountMath.makeEmpty(debtBrand); + let totalCollateral = AmountMath.makeEmpty(collateralBrand); + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ + const transfers = []; + + for (const vault of vaultsToLiquidate.values()) { + vault.liquidating(); + liquidatingVaults.add(vault); + + const collateralAmount = vault.getCollateralAmount(); + totalCollateral = AmountMath.add(totalCollateral, collateralAmount); + + const debtAmount = vault.getCurrentDebt(); + totalDebt = AmountMath.add(totalDebt, debtAmount); + transfers.push([ + vault.getVaultSeat(), + liqSeat, + { Collateral: collateralAmount }, + ]); + vaultData.init(vault, { collateralAmount, debtAmount }); + } + + if (transfers.length > 0) { + atomicRearrange(zcf, harden(transfers)); + } + + return { vaultData, totalDebt, totalCollateral, liqSeat }; +}; +harden(getLiquidatableVaults); diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/math.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/math.js new file mode 100644 index 00000000000..bab37d521e0 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/math.js @@ -0,0 +1,95 @@ +// @jessie-check + +/** + * @file calculations specific to the Vault Factory contract + * See also ../interest-math.js + */ + +import { AmountMath } from '@agoric/ertp'; +import { getAmountOut } from '@agoric/zoe/src/contractSupport/priceQuote.js'; +import { + addRatios, + ceilMultiplyBy, + floorDivideBy, + ratioGTE, +} from '@agoric/zoe/src/contractSupport/ratio.js'; +import { priceFrom } from '@agoric/inter-protocol/src/auction/util.js'; +import { addSubtract } from '@agoric/inter-protocol/src/contractSupport.js'; + +/** + * Calculate the minimum collateralization given the liquidation margin and the "padding" + * from that liquidation threshold. + * + * @param {Ratio} liquidationMargin + * @param {Ratio} liquidationPadding + * @returns {Ratio} + */ +export const calculateMinimumCollateralization = ( + liquidationMargin, + liquidationPadding, +) => addRatios(liquidationMargin, liquidationPadding); + +/** + * Calculate the lesser price of the given quotes. + * + * @param {PriceQuote} quoteA + * @param {PriceQuote} [quoteB] + * @returns {Ratio} + */ +export const minimumPrice = (quoteA, quoteB) => { + const priceA = priceFrom(quoteA); + if (quoteB === undefined) { + return priceA; + } + const priceB = priceFrom(quoteB); + if (ratioGTE(priceA, priceB)) { + return priceB; + } else { + return priceA; + } +}; +harden(minimumPrice); + +/** + * Calculate the maximum debt allowed based on the price quote and the lesser of + * the `liquidationMargin` or the `liquidationPadding`. + * + * @param {PriceQuote} quoteAmount + * @param {Ratio} liquidationMargin + * @param {Ratio} liquidationPadding + * @returns {Amount<'nat'>} + */ +export const maxDebtForVault = ( + quoteAmount, + liquidationMargin, + liquidationPadding, +) => { + const debtByQuote = getAmountOut(quoteAmount); + const minimumCollateralization = calculateMinimumCollateralization( + liquidationMargin, + liquidationPadding, + ); + // floorDivide because we want the debt ceiling lower + return floorDivideBy(debtByQuote, minimumCollateralization); +}; + +/** + * Calculate the fee, the amount to mint and the resulting debt. + * The give and the want together reflect a delta, where typically + * one is zero because they come from the gave/want of an offer + * proposal. If the `want` is zero, the `fee` will also be zero, + * so the simple math works. + * + * @param {Amount<'nat'>} currentDebt + * @param {Amount<'nat'>} give excess of currentDebt is returned in 'surplus' + * @param {Amount<'nat'>} want + * @param {Ratio} debtFee + */ +export const calculateDebtCosts = (currentDebt, give, want, debtFee) => { + const maxGive = AmountMath.min(currentDebt, give); + const surplus = AmountMath.subtract(give, maxGive); + const fee = ceilMultiplyBy(want, debtFee); + const toMint = AmountMath.add(want, fee); + const newDebt = addSubtract(currentDebt, toMint, maxGive); + return { newDebt, toMint, fee, surplus }; +}; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/orderedVaultStore.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/orderedVaultStore.js new file mode 100644 index 00000000000..7fd83d61244 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/orderedVaultStore.js @@ -0,0 +1,69 @@ +import { fromVaultKey, toVaultKey } from './storeUtils.js'; + +/** + * Used by prioritizedVaults to wrap the Collections API for this use case. + * + * Designed to be replaceable by naked Collections API when composite keys are available. + * + * In this module debts are encoded as the inverse quotient (collateral over debt) so that + * greater collateralization sorts after lower. (Higher debt-to-collateral come + * first.) + */ + +/** @typedef {import('./vault').Vault} Vault */ +/** @typedef {import('./storeUtils').CompositeKey} CompositeKey */ + +/** + * @param {MapStore} store + */ +export const makeOrderedVaultStore = store => { + /** + * + * @param {string} vaultId + * @param {Vault} vault + */ + const addVault = (vaultId, vault) => { + const debt = vault.getNormalizedDebt(); + const collateral = vault.getCollateralAmount(); + const key = toVaultKey(debt, collateral, vaultId); + store.init(key, vault); + return key; + }; + + /** + * + * @param {string} key + * @returns {Vault} + */ + const removeByKey = key => { + try { + const vault = store.get(key); + assert(vault); + store.delete(key); + return vault; + } catch (e) { + console.error( + 'removeByKey failed to remove', + key, + '[ratio, vaultId]:', + fromVaultKey(key), + ); + const keys = Array.from(store.keys()); + console.error( + ' contents:', + keys.map(k => [k, fromVaultKey[k]]), + ); + throw e; + } + }; + + return harden({ + addVault, + removeByKey, + has: store.has, + keys: store.keys, + entries: store.entries, + getSize: store.getSize, + values: store.values, + }); +}; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/params.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/params.js new file mode 100644 index 00000000000..31ec06585ee --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/params.js @@ -0,0 +1,217 @@ +// @jessie-check + +import './types.js'; + +import { + CONTRACT_ELECTORATE, + makeParamManagerSync, + ParamTypes, +} from '@agoric/governance'; +import { makeStoredPublisherKit } from '@agoric/notifier'; +import { M, makeScalarMapStore } from '@agoric/store'; +import { TimeMath } from '@agoric/time'; +import { provideDurableMapStore } from '@agoric/vat-data'; +import { subtractRatios } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { amountPattern, ratioPattern } from '@agoric/inter-protocol/src/contractSupport.js'; + +export const CHARGING_PERIOD_KEY = 'ChargingPeriod'; +export const RECORDING_PERIOD_KEY = 'RecordingPeriod'; + +export const DEBT_LIMIT_KEY = 'DebtLimit'; +export const LIQUIDATION_MARGIN_KEY = 'LiquidationMargin'; +export const LIQUIDATION_PADDING_KEY = 'LiquidationPadding'; +export const LIQUIDATION_PENALTY_KEY = 'LiquidationPenalty'; +export const INTEREST_RATE_KEY = 'InterestRate'; +export const MINT_FEE_KEY = 'MintFee'; +export const MIN_INITIAL_DEBT_KEY = 'MinInitialDebt'; +export const SHORTFALL_INVITATION_KEY = 'ShortfallInvitation'; +export const REFERENCED_UI_KEY = 'ReferencedUI'; + +export const vaultDirectorParamTypes = { + [MIN_INITIAL_DEBT_KEY]: ParamTypes.AMOUNT, + [CHARGING_PERIOD_KEY]: ParamTypes.NAT, + [RECORDING_PERIOD_KEY]: ParamTypes.NAT, + [REFERENCED_UI_KEY]: ParamTypes.STRING, +}; +harden(vaultDirectorParamTypes); + +/** + * @param {Amount<'set'>} electorateInvitationAmount + * @param {Amount<'nat'>} minInitialDebt + * @param {Amount<'set'>} shortfallInvitationAmount + * @param {string} referencedUi + * @param {InterestTiming} interestTiming + */ +const makeVaultDirectorParams = ( + electorateInvitationAmount, + minInitialDebt, + shortfallInvitationAmount, + referencedUi, + interestTiming, +) => { + return harden({ + [CONTRACT_ELECTORATE]: { + type: ParamTypes.INVITATION, + value: electorateInvitationAmount, + }, + [MIN_INITIAL_DEBT_KEY]: { + type: ParamTypes.AMOUNT, + value: minInitialDebt, + }, + [SHORTFALL_INVITATION_KEY]: { + type: ParamTypes.INVITATION, + value: shortfallInvitationAmount, + }, + [REFERENCED_UI_KEY]: { type: ParamTypes.STRING, value: referencedUi }, + [CHARGING_PERIOD_KEY]: { + type: ParamTypes.NAT, + value: TimeMath.relValue(interestTiming.chargingPeriod), + }, + [RECORDING_PERIOD_KEY]: { + type: ParamTypes.NAT, + value: TimeMath.relValue(interestTiming.recordingPeriod), + }, + }); +}; +harden(makeVaultDirectorParams); + +/** @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager').ParamTypesMapFromRecord>} VaultDirectorParams */ + +/** @type {(liquidationMargin: Ratio) => Ratio} */ +const zeroRatio = liquidationMargin => + subtractRatios(liquidationMargin, liquidationMargin); + +/** + * @param {import('@agoric/notifier').StoredPublisherKit} publisherKit + * @param {VaultManagerParamValues} initial + */ +export const makeVaultParamManager = ( + publisherKit, + { + debtLimit, + interestRate, + liquidationMargin, + liquidationPadding = zeroRatio(liquidationMargin), + liquidationPenalty, + mintFee, + }, +) => + makeParamManagerSync(publisherKit, { + [DEBT_LIMIT_KEY]: [ParamTypes.AMOUNT, debtLimit], + [INTEREST_RATE_KEY]: [ParamTypes.RATIO, interestRate], + [LIQUIDATION_PADDING_KEY]: [ParamTypes.RATIO, liquidationPadding], + [LIQUIDATION_MARGIN_KEY]: [ParamTypes.RATIO, liquidationMargin], + [LIQUIDATION_PENALTY_KEY]: [ParamTypes.RATIO, liquidationPenalty], + [MINT_FEE_KEY]: [ParamTypes.RATIO, mintFee], + }); +/** @typedef {ReturnType} VaultParamManager */ + +export const vaultParamPattern = M.splitRecord( + { + liquidationMargin: ratioPattern, + liquidationPenalty: ratioPattern, + interestRate: ratioPattern, + mintFee: ratioPattern, + debtLimit: amountPattern, + }, + { + // optional for backwards compatibility, e.g. with loadgen + liquidationPadding: ratioPattern, + }, +); + +/** + * @param {{ + * auctioneerPublicFacet: ERef, + * electorateInvitationAmount: Amount<'set'>, + * minInitialDebt: Amount<'nat'>, + * bootstrapPaymentValue: bigint, + * priceAuthority: ERef, + * timer: ERef, + * reservePublicFacet: AssetReservePublicFacet, + * interestTiming: InterestTiming, + * shortfallInvitationAmount: Amount<'set'>, + * referencedUi?: string, + * }} opts + */ +export const makeGovernedTerms = ({ + auctioneerPublicFacet, + bootstrapPaymentValue, + electorateInvitationAmount, + interestTiming, + minInitialDebt, + priceAuthority, + reservePublicFacet, + timer, + shortfallInvitationAmount, + referencedUi = 'NO REFERENCE', +}) => { + return harden({ + auctioneerPublicFacet, + priceAuthority, + reservePublicFacet, + timerService: timer, + governedParams: makeVaultDirectorParams( + electorateInvitationAmount, + minInitialDebt, + shortfallInvitationAmount, + referencedUi, + interestTiming, + ), + bootstrapPaymentValue, + }); +}; +harden(makeGovernedTerms); +/** + * Stop-gap which restores initial param values + * UNTIL https://github.com/Agoric/agoric-sdk/issues/5200 + * + * NB: changes from initial values will be lost upon restart + * + * @param {import('@agoric/vat-data').Baggage} baggage + * @param {ERef} marshaller + */ +export const provideVaultParamManagers = (baggage, marshaller) => { + /** @type {MapStore} */ + const managers = makeScalarMapStore(); + + // the managers aren't durable but their arguments are + /** @type {MapStore} */ + const managerArgs = provideDurableMapStore( + baggage, + 'vault param manager parts', + ); + + const makeManager = (brand, { storageNode, initialParamValues }) => { + const manager = makeVaultParamManager( + makeStoredPublisherKit(storageNode, marshaller, 'governance'), + initialParamValues, + ); + managers.init(brand, manager); + return manager; + }; + + // restore from baggage + [...managerArgs.entries()].map(([brand, args]) => makeManager(brand, args)); + + return { + /** + * + * @param {Brand} brand + * @param {StorageNode} storageNode + * @param {VaultManagerParamValues} initialParamValues + */ + addParamManager(brand, storageNode, initialParamValues) { + const args = harden({ storageNode, initialParamValues }); + managerArgs.init(brand, args); + return makeManager(brand, args); + }, + /** + * @param {Brand} brand + */ + get(brand) { + return managers.get(brand); + }, + }; +}; +harden(provideVaultParamManagers); diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/prioritizedVaults.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/prioritizedVaults.js new file mode 100644 index 00000000000..09384cb386c --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/prioritizedVaults.js @@ -0,0 +1,151 @@ +// @jessie-check + +import { makeTracer } from '@agoric/internal'; +import { makeRatioFromAmounts } from '@agoric/zoe/src/contractSupport/index.js'; +import { AmountMath } from '@agoric/ertp'; +import { Far } from '@endo/marshal'; +import { M } from '@agoric/vat-data'; +import { makeScalarMapStore } from '@agoric/store'; +import { makeOrderedVaultStore } from './orderedVaultStore.js'; +import { + toVaultKey, + normalizedCollRatioKey, + normalizedCollRatio, +} from './storeUtils.js'; + +/** @typedef {import('./vault').Vault} Vault */ +/** @typedef {import('./storeUtils.js').NormalizedDebt} NormalizedDebt */ + +const trace = makeTracer('PVaults', true); + +/** + * @param {Amount<'nat'>} debtAmount + * @param {Amount<'nat'>} collateralAmount + * @returns {Ratio} + */ +const calculateDebtToCollateral = (debtAmount, collateralAmount) => { + if (AmountMath.isEmpty(collateralAmount)) { + return makeRatioFromAmounts( + debtAmount, + AmountMath.make(collateralAmount.brand, 1n), + ); + } + return makeRatioFromAmounts(debtAmount, collateralAmount); +}; + +/** + * @param {Vault} vault + * @returns {Ratio} + */ +export const currentDebtToCollateral = vault => + calculateDebtToCollateral( + vault.getCurrentDebt(), + vault.getCollateralAmount(), + ); + +/** + * Vaults, ordered by their debt ratio so that all the vaults below a threshold + * can be quickly found and liquidated. + * + * @param {MapStore} store + * vault has a higher debt ratio than the previous highest + */ +export const makePrioritizedVaults = store => { + const vaults = makeOrderedVaultStore(store); + + /** + * Ratio of the least-collateralized vault, if there is one. + * + * @returns {Ratio | undefined} actual debt over collateral + */ + const highestRatio = () => { + if (vaults.getSize() === 0) { + return undefined; + } + // Get the first vault. + const [vault] = vaults.values(); + assert( + !AmountMath.isEmpty(vault.getCollateralAmount()), + 'First vault had no collateral', + ); + return currentDebtToCollateral(vault); + }; + + /** + * @param {NormalizedDebt} oldDebt + * @param {Amount<'nat'>} oldCollateral + * @param {string} vaultId + */ + const hasVaultByAttributes = (oldDebt, oldCollateral, vaultId) => { + const key = toVaultKey(oldDebt, oldCollateral, vaultId); + return vaults.has(key); + }; + + /** + * @param {string} key + * @returns {Vault} + */ + const removeVault = key => { + const vault = vaults.removeByKey(key); + return vault; + }; + + /** + * @param {NormalizedDebt} oldDebt + * @param {Amount<'nat'>} oldCollateral + * @param {string} vaultId + */ + const removeVaultByAttributes = (oldDebt, oldCollateral, vaultId) => { + const key = toVaultKey(oldDebt, oldCollateral, vaultId); + return removeVault(key); + }; + + /** + * @param {VaultId} vaultId + * @param {Vault} vault + */ + const addVault = (vaultId, vault) => { + const key = vaults.addVault(vaultId, vault); + assert( + !AmountMath.isEmpty(vault.getCollateralAmount()), + 'Tracked vaults must have collateral (be liquidatable)', + ); + return key; + }; + + const removeVaultsBelow = ({ margin, quote, interest }) => { + // crKey represents a collateralizationRatio based on the locked price, the + // interest charged so far, and the liquidation margin. + // We'll remove all vaults below that ratio, and return them. + const crKey = normalizedCollRatioKey(quote, interest, margin); + + trace( + `Liquidating vaults worse than`, + normalizedCollRatio(quote, interest, margin), + ); + + /** @type {MapStore} */ + const vaultsRemoved = makeScalarMapStore(); + + for (const [key, vault] of vaults.entries(M.lte(crKey))) { + vaultsRemoved.init(key, vault); + vaults.removeByKey(key); + } + + return vaultsRemoved; + }; + + return Far('PrioritizedVaults', { + addVault, + entries: vaults.entries, + getCount: vaults.getSize, + hasVaultByAttributes, + highestRatio, + removeVault, + removeVaultByAttributes, + removeVaultsBelow, + + // visible for testing + countVaultsBelow: crKey => vaults.getSize(M.lte(crKey)), + }); +}; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/proceeds.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/proceeds.js new file mode 100644 index 00000000000..068f99c4d6b --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/proceeds.js @@ -0,0 +1,274 @@ +import { AmountMath } from '@agoric/ertp'; +import { + ceilDivideBy, + ceilMultiplyBy, + floorMultiplyBy, + makeRatioFromAmounts, + multiplyRatios, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { quoteAsRatio, subtractToEmpty } from '@agoric/inter-protocol/src/contractSupport.js'; +import { liquidationResults } from './liquidation.js'; + +/** + * @typedef {{ + * overage: Amount<'nat'>, + * shortfallToReserve: Amount<'nat'>, + * collateralForReserve: Amount<'nat'>, + * actualCollateralSold: Amount<'nat'>, + * collateralSold: Amount<'nat'>, + * collatRemaining: Amount<'nat'>, + * debtToBurn: Amount<'nat'>, + * mintedForReserve: Amount<'nat'>, + * mintedProceeds: Amount<'nat'>, + * phantomDebt: Amount<'nat'>, + * totalPenalty: Amount<'nat'>, + * transfersToVault: Array<[number, AmountKeywordRecord]>, + * vaultsToReinstate: Array + * }} DistributionPlan + * + * The plan to execute for distributing proceeds of a liquidation. + * + * Vaults are referenced by index in the list sent to the calculator. + */ + +/** @typedef {{ collateral: Amount<'nat'>, presaleDebt: Amount<'nat'>, currentDebt: Amount<'nat'> }} VaultBalances */ + +/** + * Liquidation.md describes how to process liquidation proceeds. + * + * This function is complex and may fail. To defend against this possibility, it + * starts with a base plan and updates it. The updating is the complex portion + * and is wrapped in a try/catch. If at any point the plan revising fails, the + * plan is returned as is. + * + * @param {object} inputs + * @param {AmountKeywordRecord} inputs.proceeds + * @param {Amount<'nat'>} inputs.totalDebt + * @param {Amount<'nat'>} inputs.totalCollateral + * @param {PriceDescription} inputs.oraclePriceAtStart + * @param {Array} inputs.vaultsBalances ordered best to worst collateralized + * @param {Ratio} inputs.penaltyRate + * @returns {DistributionPlan} + */ +export const calculateDistributionPlan = ({ + proceeds, + totalDebt, + totalCollateral, + oraclePriceAtStart, + vaultsBalances, + penaltyRate, +}) => { + const emptyCollateral = AmountMath.makeEmptyFromAmount(totalCollateral); + const emptyMinted = AmountMath.makeEmptyFromAmount(totalDebt); + + const { Collateral: collateralProceeds } = proceeds; + /** @type {Amount<'nat'>} */ + const collateralSold = AmountMath.subtract( + totalCollateral, + collateralProceeds, + ); + + const mintedProceeds = proceeds.Minted || emptyMinted; + const accounting = liquidationResults(totalDebt, mintedProceeds); + + // charged in collateral + const totalPenalty = ceilMultiplyBy( + totalDebt, + multiplyRatios(penaltyRate, quoteAsRatio(oraclePriceAtStart)), + ); + + const debtPortion = makeRatioFromAmounts(totalPenalty, totalDebt); + + // We mutate the plan so that at any point if there's an error the plan can be returned and executed. + // IOW the plan must always be in a valid state, though perhaps incomplete. + /** @type {DistributionPlan} */ + const plan = { + overage: accounting.overage, + shortfallToReserve: accounting.shortfall, + collateralForReserve: emptyCollateral, + actualCollateralSold: emptyCollateral, + collateralSold, + collatRemaining: emptyCollateral, + debtToBurn: emptyMinted, + mintedForReserve: emptyMinted, + mintedProceeds, + phantomDebt: emptyMinted, + transfersToVault: [], + vaultsToReinstate: [], + totalPenalty, + }; + + /** + * If interest was charged between liquidating and liquidated, erase it. + * + * @param {VaultBalances} balances + */ + const updatePhantomDebt = ({ presaleDebt, currentDebt }) => { + if (AmountMath.isEqual(presaleDebt, currentDebt)) { + return; + } + const accrued = AmountMath.subtract(currentDebt, presaleDebt); + plan.phantomDebt = AmountMath.add(plan.phantomDebt, accrued); + }; + + const runFlow1 = () => { + // Flow #1: no shortfall + + const distributableCollateral = subtractToEmpty( + collateralProceeds, + totalPenalty, + ); + + plan.debtToBurn = totalDebt; + plan.mintedForReserve = accounting.overage; + + // return remaining funds to vaults before closing + + let leftToStage = distributableCollateral; + const price = makeRatioFromAmounts(mintedProceeds, collateralSold); + + // iterate from best to worst, returning collateral until it has + // been exhausted. Vaults after that get nothing. + for (const [vaultIndex, balances] of vaultsBalances.entries()) { + const { collateral: vCollat, presaleDebt } = balances; + updatePhantomDebt(balances); + + // max return is vault value reduced by debt and penalty value + const debtCollat = ceilDivideBy(presaleDebt, price); + const penaltyCollat = ceilMultiplyBy(presaleDebt, debtPortion); + const lessCollat = AmountMath.add(debtCollat, penaltyCollat); + + const maxCollat = subtractToEmpty(vCollat, lessCollat); + if (!AmountMath.isEmpty(leftToStage)) { + const collatReturn = AmountMath.min(leftToStage, maxCollat); + leftToStage = AmountMath.subtract(leftToStage, collatReturn); + plan.transfersToVault.push([vaultIndex, { Collateral: collatReturn }]); + } + } + + const hasCollateralToDistribute = !AmountMath.isEmpty( + distributableCollateral, + ); + plan.collateralForReserve = hasCollateralToDistribute + ? AmountMath.add(leftToStage, totalPenalty) + : collateralProceeds; + }; + + // Flow 2a: all collateral was sold but debt was not covered + const runFlow2a = () => { + // charge penalty if proceeds are sufficient + const penaltyInMinted = ceilMultiplyBy(totalDebt, penaltyRate); + const recoveredDebt = AmountMath.min( + AmountMath.add(totalDebt, penaltyInMinted), + mintedProceeds, + ); + + plan.debtToBurn = recoveredDebt; + + const distributable = subtractToEmpty(mintedProceeds, recoveredDebt); + let mintedRemaining = distributable; + + const vaultPortion = makeRatioFromAmounts(distributable, totalCollateral); + + // iterate from best to worst returning remaining funds to vaults + for (const [vaultIndex, balances] of vaultsBalances.entries()) { + // from best to worst, return minted above penalty if any remains + const vaultShare = floorMultiplyBy(balances.collateral, vaultPortion); + updatePhantomDebt(balances); + + if (!AmountMath.isEmpty(mintedRemaining)) { + const mintedToReturn = AmountMath.min(mintedRemaining, vaultShare); + mintedRemaining = AmountMath.subtract(mintedRemaining, mintedToReturn); + plan.transfersToVault.push([vaultIndex, { Minted: mintedToReturn }]); + } + } + }; + + // Flow 2b: collateral remains but debt was not covered + const runFlow2b = () => { + plan.debtToBurn = totalDebt; + plan.mintedForReserve = accounting.overage; + + // reconstitute vaults until collateral is insufficient + let reconstituteVaults = AmountMath.isGTE(collateralProceeds, totalPenalty); + + // charge penalty if proceeds are sufficient + const distributableCollateral = subtractToEmpty( + collateralProceeds, + totalPenalty, + ); + + plan.collatRemaining = distributableCollateral; + + let shortfallToReserve = accounting.shortfall; + const reduceCollateral = amount => + (plan.actualCollateralSold = AmountMath.add( + plan.actualCollateralSold, + amount, + )); + + // iterate from best to worst attempting to reconstitute, by + // returning remaining funds to vaults + for (const [vaultIndex, balances] of vaultsBalances.entries()) { + const { collateral: vCollat, presaleDebt } = balances; + + // according to #7123, Collateral for penalty = + // total liquidation penalty * vault debt / total debt + const vaultPenalty = ceilMultiplyBy(presaleDebt, debtPortion); + const collatPostPenalty = subtractToEmpty(vCollat, vaultPenalty); + const vaultDebt = floorMultiplyBy(presaleDebt, debtPortion); + + // Should we continue reconstituting vaults? + reconstituteVaults = + reconstituteVaults && + !AmountMath.isEmpty(collatPostPenalty) && + AmountMath.isGTE(plan.collatRemaining, collatPostPenalty) && + AmountMath.isGTE(totalDebt, presaleDebt); + + if (reconstituteVaults) { + plan.collatRemaining = AmountMath.subtract( + plan.collatRemaining, + collatPostPenalty, + ); + shortfallToReserve = subtractToEmpty(shortfallToReserve, presaleDebt); + // must reinstate after atomicRearrange(), so we record them. + plan.vaultsToReinstate.push(vaultIndex); + reduceCollateral(vaultDebt); + plan.transfersToVault.push([ + vaultIndex, + { Collateral: collatPostPenalty }, + ]); + } else { + updatePhantomDebt(balances); + + reduceCollateral(vCollat); + } + } + + plan.collateralForReserve = AmountMath.add( + plan.collatRemaining, + totalPenalty, + ); + + plan.shortfallToReserve = shortfallToReserve; + }; + + try { + if (AmountMath.isEmpty(accounting.shortfall)) { + // Flow #1: no shortfall + runFlow1(); + } else if (AmountMath.isEmpty(collateralProceeds)) { + // Flow 2a: all collateral was sold but debt was not covered + runFlow2a(); + } else { + // Flow #2b: There's unsold collateral; some vaults may be reinstated. + runFlow2b(); + } + } catch (err) { + console.error('🚨 Failure running distribution flow:', err); + // continue to return `plan` in the state that was reached + } + + return harden(plan); +}; +harden(calculateDistributionPlan); diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/storeUtils.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/storeUtils.js new file mode 100644 index 00000000000..ed5ea1b3e16 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/storeUtils.js @@ -0,0 +1,171 @@ +// @jessie-check + +/** + * Module to improvise composite keys for orderedVaultStore until Collections + * API supports them. + */ + +// XXX importing these that are declared to be used only for testing +// until @agoric/store supports composite keys +import { makeDecodePassable, makeEncodePassable } from '@endo/marshal'; +import { + getAmountIn, + getAmountOut, + natSafeMath, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { makeTracer } from '@agoric/internal'; + +const { multiply } = natSafeMath; + +const trace = makeTracer('Store', true); + +/** + * @typedef {import('@endo/marshal').PureData} PureData + */ + +/** + * @typedef {[normalizedCollateralization: number, vaultId: VaultId]} CompositeKey + */ + +// `makeEncodePassable` has three named options: +// `encodeRemotable`, `encodeError`, and `encodePromise`. +// Those which are omitted default to a function that always throws. +// So by omitting all three, we know that +// the resulting function will encode only `PureData` arguments. +/** + * @param {PureData} key + * @returns {string} + */ +export const encodeData = makeEncodePassable(); + +// `makeDecodePassable` has three named options: +// `decodeRemotable`, `decodeError`, and `decodePromise`. +// Those which are omitted default to a function that always throws. +// So by omitting all three, we know that +// the resulting function will decode only to `PureData` results. +/** + * @param {string} encoded + * @returns {PureData} + */ +export const decodeData = makeDecodePassable(); + +/** + * @param {number} n + * @returns {string} + */ +const encodeNumber = n => { + assert.typeof(n, 'number'); + return encodeData(n); +}; + +/** + * @param {string} encoded + * @returns {number} + */ +const decodeNumber = encoded => { + const result = decodeData(encoded); + assert.typeof(result, 'number'); + return result; +}; + +// Type annotations to support static testing of amount values +/** @typedef {Amount<'nat'> & {normalized: true}} NormalizedDebt */ +/** @typedef {Amount<'nat'> & {normalized: false}} ActualDebt */ + +/** + * Overcollateralized are greater than one. + * The more undercollaterized the smaller in [0-1]. + * + * @param {NormalizedDebt} normalizedDebt normalized (not actual) total debt + * @param {Amount<'nat'>} collateral + * @returns {number} + */ +const collateralizationRatio = (normalizedDebt, collateral) => { + const c = Number(collateral.value); + const d = normalizedDebt.value + ? Number(normalizedDebt.value) + : Number.EPSILON; + return c / d; +}; + +/** + * Sorts by ratio in descending debt. Ordering of vault id is undefined. + * + * @param {NormalizedDebt} normalizedDebt normalized (not actual) total debt + * @param {Amount<'nat'>} collateral + * @param {VaultId} vaultId + * @returns {string} lexically sortable string in which highest + * debt-to-collateral is earliest + */ +export const toVaultKey = (normalizedDebt, collateral, vaultId) => { + assert(normalizedDebt); + assert(collateral); + assert(vaultId); + // until DB supports composite keys, copy its method for turning numbers to DB + // entry keys + const numberPart = encodeNumber( + collateralizationRatio(normalizedDebt, collateral), + ); + + trace(`To Vault Key `, collateralizationRatio(normalizedDebt, collateral)); + return `${numberPart}:${vaultId}`; +}; +harden(toVaultKey); + +/** + * @param {string} key + * @returns {[normalizedCollateralization: number, vaultId: VaultId]} + */ +export const fromVaultKey = key => { + const [numberPart, vaultIdPart] = key.split(':'); + return [decodeNumber(numberPart), vaultIdPart]; +}; +harden(fromVaultKey); + +/** + * Create a float representing a Normalized Collateralization Ratio that can be + * compared to the NCRs of vaults. We want a float with as much resolution as we + * can get, so we multiply out the numerator and the denominator, and only + * divide the results once. + * + * For use by `normalizedCollRatioKey` and tests. + * + * @param {PriceQuote} quote + * @param {Ratio} compoundedInterest + * @param {Ratio} margin + * @returns {number} + */ +export const normalizedCollRatio = (quote, compoundedInterest, margin) => { + const amountIn = getAmountIn(quote).value; + const amountOut = getAmountOut(quote).value; + const interestNumerator = compoundedInterest.numerator.value; + const interestBase = compoundedInterest.denominator.value; + const numerator = multiply( + margin.numerator.value, + multiply(amountIn, interestNumerator), + ); + const denominator = multiply( + amountOut, + multiply(interestBase, margin.denominator.value), + ); + + return Number(numerator) / Number(denominator); +}; +harden(normalizedCollRatio); + +/** + * Create a sort key for a normalized collateralization ratio. We want the key + * to be based on a float with as much resolution as we can get, so we multiply + * out the numerator and the denominator, and divide only once. + * + * @param {PriceQuote} quote + * @param {Ratio} compoundedInterest + * @param {Ratio} margin + * @returns {string} lexically sortable string in which highest + * debt-to-collateral is earliest + */ +export const normalizedCollRatioKey = (quote, compoundedInterest, margin) => { + const collRatio = normalizedCollRatio(quote, compoundedInterest, margin); + return `${encodeNumber(collRatio)}:`; +}; +harden(normalizedCollRatioKey); diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/type-imports.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/type-imports.js new file mode 100644 index 00000000000..5902add2f24 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/type-imports.js @@ -0,0 +1,4 @@ +// @jessie-check + +import '@agoric/zoe/exported.js'; +import './types.js'; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/types.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/types.js new file mode 100644 index 00000000000..f7dd92a47a1 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/types.js @@ -0,0 +1,134 @@ +// @jessie-check + +/** + * @typedef {import('./vault').VaultNotification} VaultNotification + * @typedef {import('./vault').Vault} Vault + * @typedef {import('./vaultKit').VaultKit} VaultKit + * @typedef {import('./vaultManager').VaultManager} VaultManager + * @typedef {import('./vaultManager').CollateralManager} CollateralManager + * @typedef {import('@agoric/inter-protocol/src/reserve/assetReserve.js').AssetReserveLimitedCreatorFacet} AssetReserveCreatorFacet + * @typedef {import('@agoric/inter-protocol/src/reserve/assetReserve.js').AssetReservePublicFacet} AssetReservePublicFacet + * @typedef {import('@agoric/inter-protocol/src/auction/auctioneer.js').AuctioneerPublicFacet} AuctioneerPublicFacet + * @typedef {import('./vaultFactory.js').VaultFactoryContract['publicFacet']} VaultFactoryPublicFacet + * + * @typedef {import('@agoric/time/src/types').Timestamp} Timestamp + * @typedef {import('@agoric/time/src/types').RelativeTime} RelativeTime + */ + +/** + * @typedef {object} AutoswapLocal + * @property {(amount: Amount, brand: Brand) => Amount} getInputPrice + * @property {() => Invitation} makeSwapInvitation + */ + +/** + * @typedef {object} VaultManagerParamValues + * @property {Ratio} liquidationMargin - margin below which collateral will be + * liquidated to satisfy the debt. + * @property {Ratio} liquidationPenalty - penalty charged upon liquidation as proportion of debt + * @property {Ratio} interestRate - annual interest rate charged on debt positions + * @property {Ratio} mintFee - The fee (in BasisPoints) charged when creating + * or increasing a debt position. + * @property {Amount<'nat'>} debtLimit + * @property {Ratio} [liquidationPadding] - vault must maintain this in order to remove collateral or add debt + */ + +/** + * @callback AddVaultType + * @param {Issuer} collateralIssuer + * @param {Keyword} collateralKeyword + * @param {VaultManagerParamValues} params + * @returns {Promise} + */ + +/** + * @typedef {object} VaultFactoryCreatorFacet + * @property {AddVaultType} addVaultType + * @property {() => Allocation} getRewardAllocation + * @property {() => Promise>} makeCollectFeesInvitation + * @property {() => import('@agoric/time/src/types').TimerWaker} makeLiquidationWaker + * @property {() => import('@agoric/time/src/types').TimerWaker} makePriceLockWaker + */ + +/** + * @callback MintAndTransfer + * Mint new debt `toMint` and transfer the `fee` portion to the vaultFactory's reward + * pool. Then reallocate over all the seat arguments and the rewardPoolSeat. Update + * the `totalDebt` if the reallocate succeeds. + * @param {ZCFSeat} mintReceiver + * @param {Amount<'nat'>} toMint + * @param {Amount<'nat'>} fee + * @param {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} transfers + * @returns {void} + */ + +/** + * @callback BurnDebt + * + * Burn debt tokens off a seat and update + * the `totalDebt` if the reallocate succeeds. + * + * @param {Amount} toBurn + * @param {ZCFSeat} fromSeat + * @returns {void} + */ + +/** + * @typedef {object} GetVaultParams + * @property {() => Ratio} getLiquidationMargin + * @property {() => Ratio} getMintFee + * @property {() => Promise} getCollateralQuote + * @property {() => Ratio} getInterestRate - The annual interest rate on a debt position + * @property {() => RelativeTime} getChargingPeriod - The period (in seconds) at + * which interest is charged to the debt position. + * @property {() => RelativeTime} getRecordingPeriod - The period (in seconds) + * at which interest is recorded to the debt position. + */ + +/** + * @typedef {string} VaultId + */ + +/** + * @typedef {object} InterestTiming + * @property {RelativeTime} chargingPeriod in seconds + * @property {RelativeTime} recordingPeriod in seconds + */ + +/** + * @typedef {object} LiquidationStrategy + * @property {() => KeywordKeywordRecord} keywordMapping + * @property {(collateral: Amount, run: Amount) => Proposal} makeProposal + * @property {(debt: Amount) => Promise} makeInvitation + */ + +/** + * @typedef {object} Liquidator + * @property {() => Promise; penaltyRate: Ratio; }>>} makeLiquidateInvitation + */ + +/** + * @typedef {object} DebtStatus + * @property {Timestamp} latestInterestUpdate + * @property {NatValue} interest interest accrued since latestInterestUpdate + * @property {NatValue} newDebt total including principal and interest + */ + +/** + * @callback Calculate + * @param {DebtStatus} debtStatus + * @param {Timestamp} currentTime + * @returns {DebtStatus} + */ + +/** + * @typedef {object} CalculatorKit + * @property {Calculate} calculate calculate new debt for charging periods up to + * the present. + * @property {Calculate} calculateReportingPeriod calculate new debt for + * reporting periods up to the present. If some charging periods have elapsed + * that don't constitute whole reporting periods, the time is not updated past + * them and interest is not accumulated for them. + */ + +/** @typedef {{key: 'governedParams' | {collateralBrand: Brand}}} VaultFactoryParamPath */ diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vault.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vault.js new file mode 100644 index 00000000000..81e9601b347 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vault.js @@ -0,0 +1,871 @@ +import { AmountMath, AmountShape } from '@agoric/ertp'; +import { makeTracer, StorageNodeShape } from '@agoric/internal'; +import { M, prepareExoClassKit } from '@agoric/vat-data'; +import { atomicTransfer } from '@agoric/zoe/src/contractSupport/index.js'; +import { SeatShape } from '@agoric/zoe/src/typeGuards.js'; +import { + addSubtract, + allEmpty, + makeNatAmountShape, +} from '@agoric/inter-protocol/src/contractSupport.js'; +import { calculateCurrentDebt, reverseInterest } from '@agoric/inter-protocol/src/interest-math.js'; +import { UnguardedHelperI } from '@agoric/inter-protocol/src/typeGuards.js'; +import { prepareVaultKit } from './vaultKit.js'; + +import '@agoric/zoe/exported.js'; +import { calculateDebtCosts } from './math.js'; + +const { quote: q, Fail } = assert; + +const trace = makeTracer('Vault', true); + +/** @typedef {import('./storeUtils.js').NormalizedDebt} NormalizedDebt */ + +/** + * @file This has most of the logic for a Vault, to borrow Minted against collateral. + * + * The logic here is for Vault which is the majority of logic of vaults but + * the user view is the `vault` value contained in VaultKit. + * + * A note on naming convention: + * - `Pre` is used as a postfix for any mutable value retrieved *before* an + * `await`, to flag values that must used very carefully after the `await` + * - `new` is a prefix for values that describe the result of executing a + * transaction; e.g., `debt` is the value before the txn, and `newDebt` + * will be value if the txn completes. + * - the absence of one of these implies the opposite, so `newDebt` is the + * future value fo `debt`, as computed based on values after any `await` + */ + +/** + * Constants for vault phase. + * + * 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. + * If liquidation fails, vaults may remain in this state. An upgrade to the contract + * might be able to recover them. + * 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 + */ +export const Phase = /** @type {const} */ ({ + ACTIVE: 'active', + LIQUIDATING: 'liquidating', + CLOSED: 'closed', + LIQUIDATED: 'liquidated', + TRANSFER: 'transfer', +}); + +/** + * @typedef {Phase[keyof Omit]} VaultPhase + * @type {{[K in VaultPhase]: Array}} + */ +const validTransitions = { + [Phase.ACTIVE]: [Phase.LIQUIDATING, Phase.CLOSED], + [Phase.LIQUIDATING]: [Phase.LIQUIDATED, Phase.ACTIVE], + [Phase.LIQUIDATED]: [Phase.CLOSED], + [Phase.CLOSED]: [], +}; + +/** + * @typedef {Phase[keyof typeof Phase]} HolderPhase + * + * @typedef {object} VaultNotification + * @property {Amount<'nat'>} locked Amount of Collateral locked + * @property {{debt: Amount<'nat'>, interest: Ratio}} debtSnapshot 'debt' at the point the compounded interest was 'interest' + * @property {HolderPhase} vaultState + */ + +// XXX masks typedef from types.js, but using that causes circular def problems +/** + * @typedef {object} VaultManager + * @property {() => Subscriber} getAssetSubscriber + * @property {(collateralAmount: Amount) => Amount<'nat'>} maxDebtFor + * @property {() => Brand} getCollateralBrand + * @property {(base: string) => string} scopeDescription + * @property {() => Brand<'nat'>} getDebtBrand + * @property {MintAndTransfer} mintAndTransfer + * @property {(amount: Amount, seat: ZCFSeat) => void} burn + * @property {() => Ratio} getCompoundedInterest + * @property {(oldDebt: import('./storeUtils.js').NormalizedDebt, oldCollateral: Amount<'nat'>, vaultId: VaultId, vaultPhase: VaultPhase, vault: Vault) => void} handleBalanceChange + * @property {() => import('./vaultManager.js').GovernedParamGetters} getGovernedParams + */ + +/** + * @typedef {Readonly<{ + * idInManager: VaultId, + * manager: VaultManager, + * storageNode: StorageNode, + * vaultSeat: ZCFSeat, + * }>} ImmutableState + */ + +/** + * Snapshot is of the debt and compounded interest when the principal was last changed. + * + * @typedef {{ + * interestSnapshot: Ratio, + * phase: VaultPhase, + * debtSnapshot: Amount<'nat'>, + * outerUpdater: import('@agoric/zoe/src/contractSupport/recorder.js').Recorder | null, + * }} MutableState + */ + +export const VaultI = M.interface('Vault', { + getCollateralAmount: M.call().returns(AmountShape), + getCurrentDebt: M.call().returns(AmountShape), + getNormalizedDebt: M.call().returns(AmountShape), + getVaultSeat: M.call().returns(SeatShape), + initVaultKit: M.call(SeatShape, StorageNodeShape).returns(M.promise()), + liquidated: M.call().returns(undefined), + liquidating: M.call().returns(undefined), + makeAdjustBalancesInvitation: M.call().returns(M.promise()), + makeCloseInvitation: M.call().returns(M.promise()), + makeTransferInvitation: M.call().returns(M.promise()), + abortLiquidation: M.call().returns(M.string()), +}); + +const VaultStateShape = harden({ + idInManager: M.any(), + manager: M.any(), + outerUpdater: M.any(), + phase: M.any(), + storageNode: M.any(), + vaultSeat: M.any(), + interestSnapshot: M.any(), + debtSnapshot: M.any(), +}); + +/** + * @param {import('@agoric/ertp').Baggage} baggage + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit + * @param {ZCF} zcf + */ +export const prepareVault = (baggage, makeRecorderKit, zcf) => { + const makeVaultKit = prepareVaultKit(baggage, makeRecorderKit); + + const maker = prepareExoClassKit( + baggage, + 'Vault', + { + helper: UnguardedHelperI, + self: VaultI, + }, + /** + * @param {VaultManager} manager + * @param {VaultId} idInManager + * @param {StorageNode} storageNode + * @returns {ImmutableState & MutableState} + */ + (manager, idInManager, storageNode) => { + return harden({ + idInManager, + manager, + outerUpdater: null, + phase: Phase.ACTIVE, + + storageNode, + + // vaultSeat will hold the collateral until the position is retired. The + // payout from it will be handed to the user: if the vault dies early + // (because the vaultFactory vat died), they'll get all their + // collateral back. If that happens, the issuer for the Minted will be dead, + // so their position will be worthless. + vaultSeat: zcf.makeEmptySeatKit().zcfSeat, + + // Two values from the same moment + interestSnapshot: manager.getCompoundedInterest(), + debtSnapshot: AmountMath.makeEmpty(manager.getDebtBrand()), + }); + }, + { + helper: { + //#region Computed constants + collateralBrand() { + return this.state.manager.getCollateralBrand(); + }, + debtBrand() { + return this.state.manager.getDebtBrand(); + }, + + emptyCollateral() { + return AmountMath.makeEmpty(this.facets.helper.collateralBrand()); + }, + emptyDebt() { + return AmountMath.makeEmpty(this.facets.helper.debtBrand()); + }, + /** + * @typedef {{ give: { Collateral: Amount<'nat'>, Minted: Amount<'nat'> }, want: { Collateral: Amount<'nat'>, Minted: Amount<'nat'> } }} FullProposal + */ + /** + * @param {ProposalRecord} partial + * @returns {FullProposal} + */ + fullProposal(partial) { + return { + give: { + Collateral: + partial.give?.Collateral || + this.facets.helper.emptyCollateral(), + Minted: partial.give?.Minted || this.facets.helper.emptyDebt(), + }, + want: { + Collateral: + partial.want?.Collateral || + this.facets.helper.emptyCollateral(), + Minted: partial.want?.Minted || this.facets.helper.emptyDebt(), + }, + }; + }, + //#endregion + + //#region Phase logic + /** + * @param {VaultPhase} newPhase + */ + assignPhase(newPhase) { + const { state } = this; + + const { phase } = state; + const validNewPhases = validTransitions[phase]; + validNewPhases.includes(newPhase) || + Fail`Vault cannot transition from ${q(phase)} to ${q(newPhase)}`; + state.phase = newPhase; + }, + + assertActive() { + const { phase } = this.state; + phase === Phase.ACTIVE || Fail`vault not active`; + }, + + assertCloseable() { + const { phase } = this.state; + phase === Phase.ACTIVE || + phase === Phase.LIQUIDATED || + Fail`to be closed a vault must be active or liquidated, not ${phase}`; + }, + //#endregion + + /** + * Called whenever the debt is paid or created through a transaction, + * but not for interest accrual. + * + * @param {Amount<'nat'>} newDebt - principal and all accrued interest + */ + updateDebtSnapshot(newDebt) { + const { state } = this; + + // update local state + state.debtSnapshot = newDebt; + state.interestSnapshot = state.manager.getCompoundedInterest(); + }, + + /** + * Update the debt balance and propagate upwards to + * maintain aggregate debt and liquidation order. + * + * @param {NormalizedDebt} oldDebtNormalized - prior principal and all accrued interest, normalized to the launch of the vaultManager + * @param {Amount<'nat'>} oldCollateral - actual collateral + * @param {Amount<'nat'>} newDebtActual - actual principal and all accrued interest + */ + updateDebtAccounting(oldDebtNormalized, oldCollateral, newDebtActual) { + const { state, facets } = this; + const { helper } = facets; + helper.updateDebtSnapshot(newDebtActual); + // notify manager so it can notify and clean up as appropriate + state.manager.handleBalanceChange( + oldDebtNormalized, + oldCollateral, + state.idInManager, + state.phase, + facets.self, + ); + }, + + /** + * + * @param {ZCFSeat} seat + */ + getCollateralAllocated(seat) { + return seat.getAmountAllocated( + 'Collateral', + this.facets.helper.collateralBrand(), + ); + }, + getMintedAllocated(seat) { + return seat.getAmountAllocated( + 'Minted', + this.facets.helper.debtBrand(), + ); + }, + + assertVaultHoldsNoMinted() { + const { state, facets } = this; + const { vaultSeat } = state; + AmountMath.isEmpty(facets.helper.getMintedAllocated(vaultSeat)) || + Fail`Vault should be empty of Minted`; + }, + + /** + * + * @param {Amount<'nat'>} collateralAmount + * @param {Amount<'nat'>} proposedDebt + */ + assertSufficientCollateral(collateralAmount, proposedDebt) { + const { state, facets } = this; + const maxDebt = state.manager.maxDebtFor(collateralAmount); + AmountMath.isGTE(maxDebt, proposedDebt, facets.helper.debtBrand()) || + Fail`Proposed debt ${q(proposedDebt)} exceeds max ${q( + maxDebt, + )} for ${q(collateralAmount)} collateral`; + }, + + /** + * + * @param {HolderPhase} newPhase + */ + getStateSnapshot(newPhase) { + const { state, facets } = this; + + const { debtSnapshot: debt, interestSnapshot: interest } = state; + /** @type {VaultNotification} */ + return harden({ + debtSnapshot: { debt, interest }, + locked: facets.self.getCollateralAmount(), + // 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! + */ + updateUiState() { + const { state, facets } = this; + const { outerUpdater } = state; + if (!outerUpdater) { + // It's not an error to change to liquidating during transfer + return; + } + const { phase } = state; + const uiState = facets.helper.getStateSnapshot(phase); + const brand = facets.helper.collateralBrand(); + trace(brand, 'updateUiState', state.idInManager, uiState); + + switch (phase) { + case Phase.ACTIVE: + case Phase.LIQUIDATING: + case Phase.LIQUIDATED: + void outerUpdater.write(uiState); + break; + case Phase.CLOSED: + void outerUpdater.writeFinal(uiState); + state.outerUpdater = null; + break; + default: + throw Error(`unreachable vault phase: ${phase}`); + } + }, + + /** + * @param {ZCFSeat} seat + */ + async closeHook(seat) { + const { state, facets } = this; + + const { self, helper } = facets; + helper.assertCloseable(); + const { phase, vaultSeat } = state; + + // Held as keys for cleanup in the manager + const oldDebtNormalized = self.getNormalizedDebt(); + const oldCollateral = self.getCollateralAmount(); + + if (phase === Phase.ACTIVE) { + // you're paying off the debt, you get everything back. + const debt = self.getCurrentDebt(); + const { + give: { Minted: given }, + } = seat.getProposal(); + given || Fail`closing an active vault requires a give`; + + // you must pay off the entire remainder but if you offer too much, we won't + // take more than you owe + AmountMath.isGTE(given, debt) || + Fail`Offer ${q(given)} is not sufficient to pay off debt ${q( + debt, + )}`; + + // Return any overpayment + atomicTransfer( + zcf, + vaultSeat, + seat, + vaultSeat.getCurrentAllocation(), + ); + + state.manager.burn(debt, seat); + } else if (phase === Phase.LIQUIDATED) { + // Simply reallocate vault assets to the offer seat. + // Don't take anything from the offer, even if vault is underwater. + // TODO verify that returning Minted here doesn't mess up debt limits + + atomicTransfer( + zcf, + vaultSeat, + seat, + vaultSeat.getCurrentAllocation(), + ); + } else { + throw Error('only active and liquidated vaults can be closed'); + } + + seat.exit(); + helper.assignPhase(Phase.CLOSED); + helper.updateDebtSnapshot(helper.emptyDebt()); + helper.updateUiState(); + helper.assertVaultHoldsNoMinted(); + vaultSeat.exit(); + + state.manager.handleBalanceChange( + oldDebtNormalized, + oldCollateral, + state.idInManager, + state.phase, + facets.self, + ); + + return 'your vault is closed, thank you for your business'; + }, + + /** + * Calculate the fee, the amount to mint and the resulting debt. + * The give and the want together reflect a delta, where typically + * one is zero because they come from the gave/want of an offer + * proposal. If the `want` is zero, the `fee` will also be zero, + * so the simple math works. + * + * @param {Amount<'nat'>} currentDebt + * @param {Amount<'nat'>} giveAmount + * @param {Amount<'nat'>} wantAmount + */ + debtFee(currentDebt, giveAmount, wantAmount) { + const { state } = this; + + return calculateDebtCosts( + currentDebt, + giveAmount, + wantAmount, + state.manager.getGovernedParams().getMintFee(), + ); + }, + + /** + * Adjust principal and collateral (atomically for offer safety) + * + * @param {ZCFSeat} clientSeat + * @returns {string} success message + */ + adjustBalancesHook(clientSeat) { + const { state, facets } = this; + + const { self, helper } = facets; + const { vaultSeat } = state; + const fp = helper.fullProposal(clientSeat.getProposal()); + + if ( + allEmpty([ + fp.give.Collateral, + fp.give.Minted, + fp.want.Collateral, + fp.want.Minted, + ]) + ) { + clientSeat.exit(); + return 'no transaction, as requested'; + } + + // Calculate the fee, the amount to mint and the resulting debt. We'll + // verify that the target debt doesn't violate the collateralization ratio, + // then mint, reallocate, and burn. + const { newDebt, fee, surplus, toMint } = helper.debtFee( + self.getCurrentDebt(), + fp.give.Minted, + fp.want.Minted, + ); + + const normalizedDebtPre = self.getNormalizedDebt(); + const collateralPre = helper.getCollateralAllocated(vaultSeat); + + const hasWants = !allEmpty([fp.want.Collateral, fp.want.Minted]); + if (hasWants) { + const newCollateral = addSubtract( + collateralPre, + fp.give.Collateral, + fp.want.Collateral, + ); + helper.assertSufficientCollateral(newCollateral, newDebt); + } + + return helper.commitBalanceAdjustment( + clientSeat, + fp, + { + newDebt, + fee, + surplus, + toMint, + }, + { normalizedDebtPre, collateralPre }, + ); + }, + + /** + * + * @param {ZCFSeat} clientSeat + * @param {FullProposal} fp + * @param {ReturnType} costs + * @param {object} accounting + * @param {NormalizedDebt} accounting.normalizedDebtPre + * @param {Amount<'nat'>} accounting.collateralPre + * @returns {string} success message + */ + commitBalanceAdjustment( + clientSeat, + fp, + { newDebt, fee, surplus, toMint }, + { normalizedDebtPre, collateralPre }, + ) { + const { state, facets } = this; + const { helper } = facets; + const { vaultSeat } = state; + + const giveMintedTaken = AmountMath.subtract(fp.give.Minted, surplus); + + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ + const transfers = harden([ + [clientSeat, vaultSeat, { Collateral: fp.give.Collateral }], + [vaultSeat, clientSeat, { Collateral: fp.want.Collateral }], + [clientSeat, vaultSeat, { Minted: giveMintedTaken }], + // Minted into vaultSeat requires minting and so is done by mintAndTransfer + ]); + + state.manager.mintAndTransfer(clientSeat, toMint, fee, transfers); + + // parent needs to know about the change in debt + helper.updateDebtAccounting( + normalizedDebtPre, + collateralPre, + newDebt, + ); + state.manager.burn(giveMintedTaken, vaultSeat); + helper.assertVaultHoldsNoMinted(); + + helper.updateUiState(); + clientSeat.exit(); + return 'We have adjusted your balances, thank you for your business'; + }, + + /** + * + * @param {ZCFSeat} seat + * @returns {VaultKit} + */ + makeTransferInvitationHook(seat) { + const { state, facets } = this; + + const { self, helper } = facets; + helper.assertCloseable(); + seat.exit(); + + // eslint-disable-next-line no-use-before-define + const vaultKit = makeVaultKit(self, state.storageNode); + state.outerUpdater = vaultKit.vaultUpdater; + helper.updateUiState(); + + return vaultKit; + }, + }, + self: { + getVaultSeat() { + return this.state.vaultSeat; + }, + + /** + * @param {ZCFSeat} seat + * @param {StorageNode} storageNode + */ + async initVaultKit(seat, storageNode) { + const { state, facets } = this; + + const { self, helper } = facets; + + const normalizedDebtPre = self.getNormalizedDebt(); + const actualDebtPre = self.getCurrentDebt(); + (AmountMath.isEmpty(normalizedDebtPre) && + AmountMath.isEmpty(actualDebtPre)) || + Fail`vault must be empty initially`; + + const collateralPre = self.getCollateralAmount(); + trace('initVaultKit start: collateral', state.idInManager, { + actualDebtPre, + collateralPre, + }); + + // get the payout to provide access to the collateral if the + // contract abandons + const { + give: { Collateral: giveCollateral }, + want: { Minted: wantMinted }, + } = seat.getProposal(); + + const minInitialDebt = state.manager + .getGovernedParams() + .getMinInitialDebt(); + AmountMath.isGTE(wantMinted, minInitialDebt) || + Fail`Vault creation requires a minInitialDebt of ${q( + minInitialDebt, + )}`; + + const { + newDebt: newDebtPre, + fee, + toMint, + } = helper.debtFee(actualDebtPre, helper.emptyDebt(), wantMinted); + !AmountMath.isEmpty(fee) || + Fail`debt requested (${wantMinted}) too small to accrue interest`; + AmountMath.isEqual(newDebtPre, toMint) || + Fail`fee mismatch for vault`; + trace( + 'initVault', + state.idInManager, + { wantedRun: wantMinted, fee }, + self.getCollateralAmount(), + ); + + helper.assertSufficientCollateral(giveCollateral, newDebtPre); + + const { vaultSeat } = state; + state.manager.mintAndTransfer( + seat, + toMint, + fee, + harden([[seat, vaultSeat, { Collateral: giveCollateral }]]), + ); + seat.exit(); + + helper.updateDebtAccounting( + normalizedDebtPre, + collateralPre, + newDebtPre, + ); + trace('initVault updateDebtAccounting fired'); + + // So that makeVaultKit can be synchronous + const vaultKit = makeVaultKit(self, storageNode); + state.outerUpdater = vaultKit.vaultUpdater; + helper.updateUiState(); + return vaultKit; + }, + + /** + * Called by manager at start of liquidation. + */ + liquidating() { + const { facets } = this; + + const { helper } = facets; + helper.assignPhase(Phase.LIQUIDATING); + helper.updateUiState(); + }, + + /** + * Called by manager at end of liquidation, at which point all debts have been + * covered. + */ + liquidated() { + const { facets } = this; + + const { helper } = facets; + helper.updateDebtSnapshot( + // liquidated vaults retain no debt + AmountMath.makeEmpty(helper.debtBrand()), + ); + + helper.assignPhase(Phase.LIQUIDATED); + helper.updateUiState(); + }, + + /** + * Called by vaultManager when the auction wasn't able to sell the + * collateral. The liquidation fee was charged against the collateral, + * but the debt will be restored and the vault will be active again. + * Liquidation.md has details on the liquidation approach. + */ + abortLiquidation() { + const { state, facets } = this; + + const { helper } = facets; + + helper.assignPhase(Phase.ACTIVE); + helper.updateUiState(); + return state.idInManager; + }, + + makeAdjustBalancesInvitation() { + const { state, facets } = this; + const { helper } = facets; + helper.assertActive(); + + return zcf.makeInvitation( + seat => helper.adjustBalancesHook(seat), + state.manager.scopeDescription('AdjustBalances'), + undefined, + M.splitRecord({ + give: M.splitRecord( + {}, + { + // It may seem odd to give both at once but there is use case: + // To rescue a vault that's on the verge of being liquidated + // when you have limited resources, you might add collateral + // at the same time that you're repaying IST. + Collateral: makeNatAmountShape(helper.collateralBrand()), + Minted: makeNatAmountShape(helper.debtBrand()), + }, + {}, + ), + want: M.splitRecord( + {}, + { + Collateral: makeNatAmountShape(helper.collateralBrand()), + Minted: makeNatAmountShape(helper.debtBrand()), + }, + {}, + ), + }), + ); + }, + + makeCloseInvitation() { + const { state, facets } = this; + const { helper } = facets; + helper.assertCloseable(); + return zcf.makeInvitation( + seat => helper.closeHook(seat), + state.manager.scopeDescription('CloseVault'), + undefined, + M.splitRecord({ + give: M.splitRecord( + {}, + { + Minted: makeNatAmountShape(helper.debtBrand()), + }, + {}, + ), + want: M.splitRecord( + {}, + { + Collateral: makeNatAmountShape(helper.collateralBrand()), + }, + {}, + ), + }), + ); + }, + + /** + * @returns {Promise} + */ + makeTransferInvitation() { + const { state, facets } = this; + const { outerUpdater } = state; + const { self, helper } = facets; + // Bring the debt snapshot current for the final report before transfer + helper.updateDebtSnapshot(self.getCurrentDebt()); + const { + debtSnapshot: debt, + interestSnapshot: interest, + phase, + } = state; + if (outerUpdater) { + void outerUpdater.writeFinal( + helper.getStateSnapshot(Phase.TRANSFER), + ); + state.outerUpdater = null; + } + const transferState = { + debtSnapshot: { debt, interest }, + locked: self.getCollateralAmount(), + vaultState: phase, + }; + return zcf.makeInvitation( + seat => helper.makeTransferInvitationHook(seat), + state.manager.scopeDescription('TransferVault'), + transferState, + ); + }, + + // for status/debugging + + /** + * + * @returns {Amount<'nat'>} + */ + getCollateralAmount() { + const { state, facets } = this; + const { vaultSeat } = state; + const { helper } = facets; + // getCollateralAllocated would return final allocations + return vaultSeat.hasExited() + ? helper.emptyCollateral() + : helper.getCollateralAllocated(vaultSeat); + }, + + /** + * The actual current debt, including accrued interest. + * + * This looks like a simple getter but it does a lot of the heavy lifting for + * interest accrual. Rather than updating all records when interest accrues, + * the vault manager updates just its rolling compounded interest. Here we + * calculate what the current debt is given what's recorded in this vault and + * what interest has compounded since this vault record was written. + * + * @see getNormalizedDebt + * + * @returns {Amount<'nat'>} + */ + getCurrentDebt() { + const { state } = this; + return calculateCurrentDebt( + state.debtSnapshot, + state.interestSnapshot, + state.manager.getCompoundedInterest(), + ); + }, + + /** + * The normalization puts all debts on a common time-independent scale since + * the launch of this vault manager. This allows the manager to order vaults + * by their debt-to-collateral ratios without having to mutate the debts as + * the interest accrues. + * + * @see getActualDebAmount + * + * @returns {import('./storeUtils.js').NormalizedDebt} as if the vault was open at the launch of this manager, before any interest accrued + */ + getNormalizedDebt() { + const { state } = this; + // @ts-expect-error cast + return reverseInterest(state.debtSnapshot, state.interestSnapshot); + }, + }, + }, + { + stateShape: VaultStateShape, + }, + ); + return maker; +}; + +/** @typedef {ReturnType>['self']} Vault */ diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultDirector.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultDirector.js new file mode 100644 index 00000000000..55f5808acd5 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultDirector.js @@ -0,0 +1,537 @@ +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; + +import '@agoric/governance/exported.js'; + +import { AmountMath, AmountShape, BrandShape, IssuerShape } from '@agoric/ertp'; +import { GovernorFacetShape } from '@agoric/governance/src/typeGuards.js'; +import { makeTracer } from '@agoric/internal'; +import { M, mustMatch } from '@agoric/store'; +import { + prepareExoClassKit, + provide, + provideDurableMapStore, +} from '@agoric/vat-data'; +import { assertKeywordName } from '@agoric/zoe/src/cleanProposal.js'; +import { + atomicRearrange, + makeRecorderTopic, + provideEmptySeat, + SubscriberShape, + TopicsRecordShape, + unitAmount, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; +import { makeCollectFeesInvitation } from '@agoric/inter-protocol/src/collectFees.js'; +import { + setWakeupsForNextAuction, + watchForGovernanceChange, +} from './liquidation.js'; +import { + provideVaultParamManagers, + SHORTFALL_INVITATION_KEY, + vaultParamPattern, +} from './params.js'; +import { + prepareVaultManagerKit, + provideAndStartVaultManagerKits, +} from './vaultManager.js'; + +const { Fail, quote: q } = assert; + +const trace = makeTracer('VD', true); + +/** + * @typedef {{ + * collaterals: Brand[], + * rewardPoolAllocation: AmountKeywordRecord, + * }} MetricsNotification + * + * @typedef {Readonly<{ + * }>} ImmutableState + * + * @typedef {{ + * }} MutableState + * + * @typedef {ImmutableState & MutableState} State + * + * @typedef {{ + * burnDebt: BurnDebt, + * getGovernedParams: (collateralBrand: Brand) => import('./vaultManager.js').GovernedParamGetters, + * mintAndTransfer: MintAndTransfer, + * getShortfallReporter: () => Promise, + * }} FactoryPowersFacet + * + * @typedef {Readonly<{ + * state: State; + * }>} MethodContext + * + * @typedef {import('@agoric/governance/src/contractGovernance/typedParamManager').TypedParamManager} VaultDirectorParamManager + */ + +const shortfallInvitationKey = 'shortfallInvitation'; + +/** + * @param {import('@agoric/ertp').Baggage} baggage + * @param {import('./vaultFactory.js').VaultFactoryZCF} zcf + * @param {VaultDirectorParamManager} directorParamManager + * @param {ZCFMint<"nat">} debtMint + * @param {ERef} timer + * @param {ERef} auctioneer + * @param {ERef} storageNode + * @param {ERef} marshaller + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeERecorderKit} makeERecorderKit + */ +const prepareVaultDirector = ( + baggage, + zcf, + directorParamManager, + debtMint, + timer, + auctioneer, + storageNode, + marshaller, + makeRecorderKit, + makeERecorderKit, +) => { + /** @type {import('@agoric/inter-protocol/src/reserve/assetReserve.js').ShortfallReporter} */ + let shortfallReporter; + + /** For holding newly minted tokens until transferred */ + const { zcfSeat: mintSeat } = zcf.makeEmptySeatKit(); + + const rewardPoolSeat = provideEmptySeat(zcf, baggage, 'rewardPoolSeat'); + + /** @type {MapStore} index of manager for the given collateral */ + const collateralManagers = provideDurableMapStore( + baggage, + 'collateralManagers', + ); + + // Non-durable map because param managers aren't durable. + // In the event they're needed they can be reconstructed from contract terms and off-chain data. + /** a powerful object; can modify parameters */ + const vaultParamManagers = provideVaultParamManagers(baggage, marshaller); + + const metricsNode = E(storageNode).makeChildNode('metrics'); + + const metricsKit = makeERecorderKit( + metricsNode, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( + M.any() + ), + ); + + const managersNode = E(storageNode).makeChildNode('managers'); + + /** + * @returns {MetricsNotification} + */ + const sampleMetrics = () => { + return harden({ + collaterals: Array.from(collateralManagers.keys()), + rewardPoolAllocation: rewardPoolSeat.getCurrentAllocation(), + }); + }; + const writeMetrics = () => E(metricsKit.recorderP).write(sampleMetrics()); + + const updateShortfallReporter = async () => { + const oldInvitation = baggage.has(shortfallInvitationKey) + ? baggage.get(shortfallInvitationKey) + : undefined; + const newInvitation = await directorParamManager.getInternalParamValue( + SHORTFALL_INVITATION_KEY, + ); + + if (newInvitation === oldInvitation) { + shortfallReporter || + Fail`updateShortFallReported called with repeat invitation and no prior shortfallReporter`; + return; + } + + // Update the values + const zoe = zcf.getZoeService(); + // @ts-expect-error cast + shortfallReporter = E(E(zoe).offer(newInvitation)).getOfferResult(); + if (oldInvitation === undefined) { + baggage.init(shortfallInvitationKey, newInvitation); + } else { + baggage.set(shortfallInvitationKey, newInvitation); + } + }; + + const factoryPowers = Far('vault factory powers', { + /** + * Get read-only params for this manager and its director. This grants all + * managers access to params from all managers. It's not POLA but it's a + * public authority and it reduces the number of distinct power objects to + * create. + * + * @param {Brand} brand + */ + getGovernedParams: brand => { + const vaultParamManager = vaultParamManagers.get(brand); + return Far('vault manager param manager', { + // merge director and manager params + ...directorParamManager.readonly(), + ...vaultParamManager.readonly(), + // redeclare these getters as to specify the kind of the Amount + getMinInitialDebt: /** @type {() => Amount<'nat'>} */ ( + directorParamManager.readonly().getMinInitialDebt + ), + getDebtLimit: /** @type {() => Amount<'nat'>} */ ( + vaultParamManager.readonly().getDebtLimit + ), + }); + }, + + /** + * Let the manager add rewards to the rewardPoolSeat without + * exposing the rewardPoolSeat to them. + * + * @type {MintAndTransfer} + */ + mintAndTransfer: (mintReceiver, toMint, fee, nonMintTransfers) => { + const kept = AmountMath.subtract(toMint, fee); + debtMint.mintGains(harden({ Minted: toMint }), mintSeat); + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart[]} */ + const transfers = [ + ...nonMintTransfers, + [mintSeat, rewardPoolSeat, { Minted: fee }], + [mintSeat, mintReceiver, { Minted: kept }], + ]; + try { + atomicRearrange(zcf, harden(transfers)); + } catch (e) { + console.error('mintAndTransfer failed to rearrange', e); + // If the rearrange fails, burn the newly minted tokens. + // Assume this won't fail because it relies on the internal mint. + // (Failure would imply much larger problems.) + debtMint.burnLosses(harden({ Minted: toMint }), mintSeat); + throw e; + } + void writeMetrics(); + }, + getShortfallReporter: async () => { + await updateShortfallReporter(); + return shortfallReporter; + }, + /** + * @param {Amount<'nat'>} toBurn + * @param {ZCFSeat} seat + */ + burnDebt: (toBurn, seat) => { + debtMint.burnLosses(harden({ Minted: toBurn }), seat); + }, + }); + + const makeVaultManagerKit = prepareVaultManagerKit(baggage, { + makeERecorderKit, + makeRecorderKit, + marshaller, + factoryPowers, + zcf, + }); + + const vaultManagers = provideAndStartVaultManagerKits(baggage); + + /** @type {(brand: Brand) => VaultManager} */ + const managerForCollateral = brand => { + const managerIndex = collateralManagers.get(brand); + const manager = vaultManagers.get(managerIndex).self; + manager || Fail`no manager ${managerIndex} for collateral ${brand}`; + return manager; + }; + + // TODO helper to make all the topics at once + const topics = harden({ + metrics: makeRecorderTopic('Vault Factory metrics', metricsKit), + }); + + const allManagersDo = fn => { + for (const managerIndex of collateralManagers.values()) { + const vm = vaultManagers.get(managerIndex).self; + fn(vm); + } + }; + + const makeWaker = (name, func) => { + return Far(name, { + wake: timestamp => func(timestamp), + }); + }; + + /** + * @returns {State} + */ + const initState = () => { + return {}; + }; + + /** + * "Director" of the vault factory, overseeing "vault managers". + * + * @param {import('./vaultFactory.js').VaultFactoryZCF} zcf + * @param {VaultDirectorParamManager} directorParamManager + * @param {ZCFMint<"nat">} debtMint + */ + const makeVaultDirector = prepareExoClassKit( + baggage, + 'VaultDirector', + { + creator: M.interface('creator', { + ...GovernorFacetShape, + }), + machine: M.interface('machine', { + addVaultType: M.call(IssuerShape, M.string(), M.record()).returns( + M.promise(), + ), + makeCollectFeesInvitation: M.call().returns(M.promise()), + getRewardAllocation: M.call().returns({ Minted: AmountShape }), + makePriceLockWaker: M.call().returns(M.remotable('TimerWaker')), + makeLiquidationWaker: M.call().returns(M.remotable('TimerWaker')), + makeReschedulerWaker: M.call().returns(M.remotable('TimerWaker')), + }), + public: M.interface('public', { + getCollateralManager: M.call(BrandShape).returns(M.remotable()), + getDebtIssuer: M.call().returns(IssuerShape), + getSubscription: M.call({ collateralBrand: BrandShape }).returns( + SubscriberShape, + ), + getElectorateSubscription: M.call().returns(SubscriberShape), + getGovernedParams: M.call({ collateralBrand: BrandShape }).returns( + M.record(), + ), + getInvitationAmount: M.call(M.string()).returns(AmountShape), + getPublicTopics: M.call().returns(TopicsRecordShape), + }), + helper: M.interface('helper', { + resetWakeupsForNextAuction: M.call().returns(M.promise()), + start: M.call().returns(M.promise()), + }), + }, + initState, + { + creator: { + getParamMgrRetriever: () => + Far('paramManagerRetriever', { + /** @param {VaultFactoryParamPath} paramPath */ + get: ( + paramPath = { key: /** @type {const} */ 'governedParams' }, + ) => { + if (paramPath.key === 'governedParams') { + return directorParamManager; + } else if (paramPath.key.collateralBrand) { + return vaultParamManagers.get(paramPath.key.collateralBrand); + } else { + assert.fail('Unsupported paramPath'); + } + }, + }), + /** + * @param {string} name + */ + getInvitation(name) { + return directorParamManager.getInternalParamValue(name); + }, + getLimitedCreatorFacet() { + return this.facets.machine; + }, + /** @returns {ERef} */ + getGovernedApis() { + // @ts-expect-error cast + return Far('governedAPIs', {}); + }, + getGovernedApiNames() { + return harden([]); + }, + setOfferFilter: strings => zcf.setOfferFilter(strings), + }, + machine: { + // TODO move this under governance #3924 + /** + * @param {Issuer<'nat'>} collateralIssuer + * @param {Keyword} collateralKeyword + * @param {VaultManagerParamValues} initialParamValues + */ + async addVaultType( + collateralIssuer, + collateralKeyword, + initialParamValues, + ) { + trace('addVaultType', collateralKeyword, initialParamValues); + mustMatch(collateralIssuer, M.remotable(), 'collateralIssuer'); + assertKeywordName(collateralKeyword); + mustMatch( + initialParamValues, + vaultParamPattern, + 'initialParamValues', + ); + await zcf.saveIssuer(collateralIssuer, collateralKeyword); + const collateralBrand = zcf.getBrandForIssuer(collateralIssuer); + // We create only one vault per collateralType. + !collateralManagers.has(collateralBrand) || + Fail`Collateral brand ${q(collateralBrand)} has already been added`; + + // zero-based index of the manager being made + const managerIndex = vaultManagers.length(); + const managerId = `manager${managerIndex}`; + const managerStorageNode = await E(managersNode).makeChildNode( + managerId, + ); + + vaultParamManagers.addParamManager( + collateralBrand, + managerStorageNode, + initialParamValues, + ); + + const startTimeStamp = await E(timer).getCurrentTimestamp(); + + const collateralUnit = await unitAmount(collateralBrand); + + const kit = await makeVaultManagerKit({ + debtMint, + collateralBrand, + collateralUnit, + descriptionScope: managerId, + startTimeStamp, + storageNode: managerStorageNode, + }); + vaultManagers.add(kit); + vaultManagers.length() - 1 === managerIndex || + Fail`mismatch VaultManagerKit count`; + const { self: vm } = kit; + vm || Fail`no vault`; + collateralManagers.init(collateralBrand, managerIndex); + void writeMetrics(); + return vm; + }, + makeCollectFeesInvitation() { + return makeCollectFeesInvitation( + zcf, + rewardPoolSeat, + debtMint.getIssuerRecord().brand, + 'Minted', + ); + }, + // XXX accessors for tests + getRewardAllocation() { + return rewardPoolSeat.getCurrentAllocation(); + }, + + makeLiquidationWaker() { + return makeWaker('liquidationWaker', _timestamp => { + allManagersDo(vm => vm.liquidateVaults(auctioneer)); + }); + }, + makeReschedulerWaker() { + const { facets } = this; + return makeWaker('reschedulerWaker', () => { + void facets.helper.resetWakeupsForNextAuction(); + }); + }, + makePriceLockWaker() { + return makeWaker('priceLockWaker', () => { + allManagersDo(vm => vm.lockOraclePrices()); + }); + }, + }, + public: { + /** + * @param {Brand} brandIn + */ + getCollateralManager(brandIn) { + collateralManagers.has(brandIn) || + Fail`Not a supported collateral type ${brandIn}`; + /** @type {VaultManager} */ + return managerForCollateral(brandIn).getPublicFacet(); + }, + getDebtIssuer() { + return debtMint.getIssuerRecord().issuer; + }, + /** + * subscription for the paramManager for a particular vaultManager + * + * @param {{ collateralBrand: Brand }} selector + */ + getSubscription({ collateralBrand }) { + return vaultParamManagers.get(collateralBrand).getSubscription(); + }, + getPublicTopics() { + return topics; + }, + /** + * subscription for the paramManager for the vaultFactory's electorate + */ + getElectorateSubscription() { + return directorParamManager.getSubscription(); + }, + /** + * @param {{ collateralBrand: Brand }} selector + */ + getGovernedParams({ collateralBrand }) { + // TODO use named getters of TypedParamManager + return vaultParamManagers.get(collateralBrand).getParams(); + }, + /** + * @param {string} name + */ + getInvitationAmount(name) { + return directorParamManager.getInvitationAmount(name); + }, + }, + helper: { + resetWakeupsForNextAuction() { + const { facets } = this; + + const priceLockWaker = facets.machine.makePriceLockWaker(); + const liquidationWaker = facets.machine.makeLiquidationWaker(); + const rescheduleWaker = facets.machine.makeReschedulerWaker(); + return setWakeupsForNextAuction( + auctioneer, + timer, + priceLockWaker, + liquidationWaker, + rescheduleWaker, + ); + }, + /** + * Start non-durable processes (or restart if needed after vat restart) + */ + async start() { + const { helper, machine } = this.facets; + + await helper.resetWakeupsForNextAuction(); + updateShortfallReporter().catch(err => + console.error( + '🛠️ updateShortfallReporter failed during start(); repair by updating governance', + err, + ), + ); + // independent of the other one which can be canceled + const rescheduleWaker = machine.makeReschedulerWaker(); + void watchForGovernanceChange(auctioneer, timer, rescheduleWaker); + }, + }, + }, + ); + return makeVaultDirector; +}; +harden(prepareVaultDirector); + +/** + * Prepare the VaultDirector kind, get or make the singleton + * + * @type {(...pvdArgs: Parameters) => ReturnType>} + */ +export const provideDirector = (...args) => { + const makeVaultDirector = prepareVaultDirector(...args); + + const [baggage] = args; + + return provide(baggage, 'director', makeVaultDirector); +}; +harden(provideDirector); diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultFactory.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultFactory.js new file mode 100644 index 00000000000..b2a187e931c --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultFactory.js @@ -0,0 +1,149 @@ +// @jessie-check + +import '@agoric/governance/exported.js'; +import '@agoric/zoe/exported.js'; +import '@agoric/zoe/src/contracts/exported.js'; + +// The vaultFactory owns a number of VaultManagers and a mint for Minted. +// +// addVaultType is a closely held method that adds a brand new collateral type. +// It specifies the initial exchange rate for that type. It depends on a +// separately specified mechanism to liquidate vaults that are +// in arrears. + +// This contract wants to be managed by a contractGovernor, but it isn't +// compatible with contractGovernor, since it has a separate paramManager for +// each VaultManager. This requires it to manually replicate the API of +// contractHelper to satisfy contractGovernor. It needs to return a creatorFacet +// with { getParamMgrRetriever, getInvitation, getLimitedCreatorFacet }. + +import { CONTRACT_ELECTORATE } from '@agoric/governance'; +import { makeParamManagerFromTerms } from '@agoric/governance/src/contractGovernance/typedParamManager.js'; +import { validateElectorate } from '@agoric/governance/src/contractHelper.js'; +import { makeTracer, StorageNodeShape } from '@agoric/internal'; +import { makeStoredSubscription, makeSubscriptionKit } from '@agoric/notifier'; +import { M } from '@agoric/store'; +import { provideAll } from '@agoric/zoe/src/contractSupport/durability.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import { E } from '@endo/eventual-send'; +import { FeeMintAccessShape } from '@agoric/zoe/src/typeGuards.js'; +import { InvitationShape } from '@agoric/inter-protocol/src/auction/params.js'; +import { SHORTFALL_INVITATION_KEY, vaultDirectorParamTypes } from './params.js'; +import { provideDirector } from './vaultDirector.js'; + +const trace = makeTracer('VF', true); + +/** + * @typedef {ZCF & { + * auctioneerPublicFacet: import('@agoric/inter-protocol/src/auction/auctioneer.js').AuctioneerPublicFacet, + * priceAuthority: ERef, + * reservePublicFacet: AssetReservePublicFacet, + * timerService: import('@agoric/time/src/types').TimerService, + * }>} VaultFactoryZCF + */ + +export const privateArgsShape = M.splitRecord( + harden({ + marshaller: M.remotable('Marshaller'), + storageNode: StorageNodeShape, + }), + harden({ + // only necessary on first invocation, not subsequent + feeMintAccess: FeeMintAccessShape, + initialPoserInvitation: InvitationShape, + initialShortfallInvitation: InvitationShape, + }), +); +harden(privateArgsShape); + +/** + * @param {VaultFactoryZCF} zcf + * @param {{ + * feeMintAccess: FeeMintAccess, + * initialPoserInvitation: Invitation, + * initialShortfallInvitation: Invitation, + * storageNode: ERef, + * marshaller: ERef, + * }} privateArgs + * @param {import('@agoric/ertp').Baggage} baggage + */ +export const prepare = async (zcf, privateArgs, baggage) => { + trace('prepare start', privateArgs, [...baggage.keys()]); + const { + initialPoserInvitation, + initialShortfallInvitation, + marshaller, + storageNode, + } = privateArgs; + + trace('awaiting debtMint'); + const { debtMint } = await provideAll(baggage, { + debtMint: () => zcf.registerFeeMint('Minted', privateArgs.feeMintAccess), + }); + + zcf.setTestJig(() => ({ + mintedIssuerRecord: debtMint.getIssuerRecord(), + })); + + const { timerService, auctioneerPublicFacet } = zcf.getTerms(); + + const { makeRecorderKit, makeERecorderKit } = prepareRecorderKitMakers( + baggage, + marshaller, + ); + + trace('making non-durable publishers'); + // XXX non-durable, will sever upon vat restart + const governanceSubscriptionKit = makeSubscriptionKit(); + const governanceNode = E(storageNode).makeChildNode('governance'); + const governanceSubscriber = makeStoredSubscription( + governanceSubscriptionKit.subscription, + governanceNode, + marshaller, + ); + /** a powerful object; can modify the invitation */ + trace('awaiting makeParamManagerFromTerms'); + const vaultDirectorParamManager = await makeParamManagerFromTerms( + { + publisher: governanceSubscriptionKit.publication, + subscriber: governanceSubscriber, + }, + zcf, + { + [CONTRACT_ELECTORATE]: initialPoserInvitation, + [SHORTFALL_INVITATION_KEY]: initialShortfallInvitation, + }, + vaultDirectorParamTypes, + ); + + const director = provideDirector( + baggage, + zcf, + vaultDirectorParamManager, + debtMint, + timerService, + auctioneerPublicFacet, + storageNode, + // XXX remove Recorder makers; remove once we excise deprecated kits for governance + marshaller, + makeRecorderKit, + makeERecorderKit, + ); + + // cannot await because it would make remote calls during vat restart + director.helper.start().catch(err => { + console.error('💀 vaultDirector failed to start:', err); + zcf.shutdownWithFailure(err); + }); + + // validate async to wait for params to be finished + // UNTIL https://github.com/Agoric/agoric-sdk/issues/4343 + void validateElectorate(zcf, vaultDirectorParamManager); + + return harden({ + creatorFacet: director.creator, + publicFacet: director.public, + }); +}; + +/** @typedef {ContractOf} VaultFactoryContract */ diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultHolder.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultHolder.js new file mode 100644 index 00000000000..db7bbaa5fa8 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultHolder.js @@ -0,0 +1,129 @@ +/** + * @file Use-object for the owner of a vault + */ +import { AmountShape } from '@agoric/ertp'; +import { M, prepareExoClassKit } from '@agoric/vat-data'; +import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; +import { UnguardedHelperI } from '@agoric/inter-protocol/src/typeGuards.js'; + +const { Fail } = assert; + +/** + * @typedef {{ + * topicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, + * vault: Vault | null, + * }} State + */ + +const HolderI = M.interface('holder', { + getCollateralAmount: M.call().returns(AmountShape), + getCurrentDebt: M.call().returns(AmountShape), + getNormalizedDebt: M.call().returns(AmountShape), + getPublicTopics: M.call().returns(TopicsRecordShape), + makeAdjustBalancesInvitation: M.call().returns(M.promise()), + makeCloseInvitation: M.call().returns(M.promise()), + makeTransferInvitation: M.call().returns(M.promise()), +}); + +/** @type {{ [name: string]: [ description: string, valueShape: Pattern ] }} */ +const PUBLIC_TOPICS = { + vault: ['Vault holder status', M.any()], +}; + +/** + * @param {import('@agoric/ertp').Baggage} baggage + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit + */ +export const prepareVaultHolder = (baggage, makeRecorderKit) => { + const makeVaultHolderKit = prepareExoClassKit( + baggage, + 'Vault Holder', + { + helper: UnguardedHelperI, + holder: HolderI, + invitationMakers: M.interface('invitationMakers', { + AdjustBalances: M.call().returns(M.promise()), + CloseVault: M.call().returns(M.promise()), + TransferVault: M.call().returns(M.promise()), + }), + }, + /** + * + * @param {Vault} vault + * @param {StorageNode} storageNode + * @returns {State} + */ + (vault, storageNode) => { + // must be the fully synchronous maker because the kit is held in durable state + const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.vault[1]); + + return { topicKit, vault }; + }, + { + helper: { + /** + * @throws if this holder no longer owns the vault + */ + owned() { + const { vault } = this.state; + if (!vault) { + throw Fail`Using vault holder after transfer`; + } + return vault; + }, + getUpdater() { + return this.state.topicKit.recorder; + }, + }, + invitationMakers: { + AdjustBalances() { + return this.facets.holder.makeAdjustBalancesInvitation(); + }, + CloseVault() { + return this.facets.holder.makeCloseInvitation(); + }, + TransferVault() { + return this.facets.holder.makeTransferInvitation(); + }, + }, + holder: { + getPublicTopics() { + const { topicKit } = this.state; + return harden({ + vault: { + description: PUBLIC_TOPICS.vault[0], + subscriber: topicKit.subscriber, + storagePath: topicKit.recorder.getStoragePath(), + }, + }); + }, + makeAdjustBalancesInvitation() { + return this.facets.helper.owned().makeAdjustBalancesInvitation(); + }, + makeCloseInvitation() { + return this.facets.helper.owned().makeCloseInvitation(); + }, + /** + * Starting a transfer revokes the vault holder. The associated updater will + * get a special notification that the vault is being transferred. + */ + makeTransferInvitation() { + const vault = this.facets.helper.owned(); + this.state.vault = null; + return vault.makeTransferInvitation(); + }, + // for status/debugging + getCollateralAmount() { + return this.facets.helper.owned().getCollateralAmount(); + }, + getCurrentDebt() { + return this.facets.helper.owned().getCurrentDebt(); + }, + getNormalizedDebt() { + return this.facets.helper.owned().getNormalizedDebt(); + }, + }, + }, + ); + return makeVaultHolderKit; +}; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultKit.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultKit.js new file mode 100644 index 00000000000..354ac3bf5d5 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultKit.js @@ -0,0 +1,46 @@ +// @jessie-check + +import '@agoric/zoe/exported.js'; + +import { makeTracer } from '@agoric/internal'; +import { prepareVaultHolder } from './vaultHolder.js'; + +const trace = makeTracer('VK', true); + +/** + * Wrap the VaultHolder duration object in a record suitable for the result of an invitation. + * + * @param {import('@agoric/ertp').Baggage} baggage + * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit + */ +export const prepareVaultKit = (baggage, makeRecorderKit) => { + trace('prepareVaultKit', [...baggage.keys()]); + + const makeVaultHolder = prepareVaultHolder(baggage, makeRecorderKit); + /** + * Create a kit of utilities for use of the vault. + * + * @param {Vault} vault + * @param {StorageNode} storageNode + */ + const makeVaultKit = (vault, storageNode) => { + trace('prepareVaultKit makeVaultKit'); + const { holder, helper, invitationMakers } = makeVaultHolder( + vault, + storageNode, + ); + const holderTopics = holder.getPublicTopics(); + const vaultKit = harden({ + publicSubscribers: { + vault: holderTopics.vault, + }, + invitationMakers, + vault: holder, + vaultUpdater: helper.getUpdater(), + }); + return vaultKit; + }; + return makeVaultKit; +}; + +/** @typedef {(ReturnType>)} VaultKit */ diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultManager.js b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultManager.js new file mode 100644 index 00000000000..015fe9911c6 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultManager.js @@ -0,0 +1,1232 @@ +/* eslint-disable consistent-return */ +/** + * @file Vault Manager object manages vault-based debts for a collateral type. + * + * The responsibilities include: + * - opening a new vault backed by the collateral + * - publishing metrics on the vault economy for that collateral + * - charging interest on all active vaults + * - liquidating active vaults that have exceeded the debt ratio + * + * Once a vault is settled (liquidated or closed) it can still be used, traded, + * etc. but is no longer the concern of the manager. It can't be liquidated, + * have interest charged, or be counted in the metrics. + * + * Undercollateralized vaults can have their assets sent to the auctioneer to be + * liquidated. If the auction is unsuccessful, the liquidation may be reverted. + */ +import '@agoric/zoe/exported.js'; + +import { + AmountMath, + AmountShape, + BrandShape, + NotifierShape, + RatioShape, +} from '@agoric/ertp'; +import { makeTracer } from '@agoric/internal'; +import { makeStoredNotifier, observeNotifier } from '@agoric/notifier'; +import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; +import { + M, + makeScalarBigMapStore, + makeScalarBigSetStore, + prepareExoClassKit, + provide, +} from '@agoric/vat-data'; +import { TransferPartShape } from '@agoric/zoe/src/contractSupport/atomicTransfer.js'; +import { + atomicRearrange, + ceilMultiplyBy, + floorDivideBy, + getAmountIn, + getAmountOut, + makeEphemeraProvider, + makeRatio, + makeRecorderTopic, + offerTo, + SubscriberShape, + TopicsRecordShape, +} from '@agoric/zoe/src/contractSupport/index.js'; +import { PriceQuoteShape, SeatShape } from '@agoric/zoe/src/typeGuards.js'; +import { E } from '@endo/eventual-send'; +import { AuctionPFShape } from '@agoric/inter-protocol/src/auction/auctioneer.js'; +import { + checkDebtLimit, + makeNatAmountShape, + quoteAsRatio, +} from '@agoric/inter-protocol/src/contractSupport.js'; +import { chargeInterest } from '@agoric/inter-protocol/src/interest.js'; +import { getLiquidatableVaults } from './liquidation.js'; +import { calculateMinimumCollateralization, minimumPrice } from './math.js'; +import { makePrioritizedVaults } from './prioritizedVaults.js'; +import { Phase, prepareVault } from './vault.js'; +import { calculateDistributionPlan } from './proceeds.js'; + +const { details: X, Fail, quote: q } = assert; + +const trace = makeTracer('VM'); + +/** @typedef {import('./storeUtils.js').NormalizedDebt} NormalizedDebt */ +/** @typedef {import('@agoric/time/src/types').RelativeTime} RelativeTime */ + +// Metrics naming scheme: nouns are present values; past-participles are accumulative. +/** + * @typedef {object} MetricsNotification + * + * @property {Ratio | null} lockedQuote priceQuote that will be used for liquidation. + * Non-null from priceLock time until liquidation has taken place. + * @property {number} numActiveVaults present count of vaults + * @property {number} numLiquidatingVaults present count of liquidating vaults + * @property {Amount<'nat'>} totalCollateral present sum of collateral across all vaults + * @property {Amount<'nat'>} totalDebt present sum of debt across all vaults + * @property {Amount<'nat'>} retainedCollateral collateral held as a result of not returning excess refunds + * to owners of vaults liquidated with shortfalls + * @property {Amount<'nat'>} liquidatingCollateral present sum of collateral in vaults sent for liquidation + * @property {Amount<'nat'>} liquidatingDebt present sum of debt in vaults sent for liquidation + * + * @property {Amount<'nat'>} totalCollateralSold running sum of collateral sold in liquidation + * @property {Amount<'nat'>} totalOverageReceived running sum of overages, central received greater than debt + * @property {Amount<'nat'>} totalProceedsReceived running sum of minted received from liquidation + * @property {Amount<'nat'>} totalShortfallReceived running sum of shortfalls, minted received less than debt + * @property {number} numLiquidationsCompleted running count of liquidated vaults + * @property {number} numLiquidationsAborted running count of vault liquidations that were reverted. + */ + +/** + * @typedef {{ + * compoundedInterest: Ratio, + * interestRate: Ratio, + * latestInterestUpdate: Timestamp, + * }} AssetState + * + * @typedef {{ + * getChargingPeriod: () => RelativeTime, + * getRecordingPeriod: () => RelativeTime, + * getDebtLimit: () => Amount<'nat'>, + * getInterestRate: () => Ratio, + * getLiquidationPadding: () => Ratio, + * getLiquidationMargin: () => Ratio, + * getLiquidationPenalty: () => Ratio, + * getMintFee: () => Ratio, + * getMinInitialDebt: () => Amount<'nat'>, + * }} GovernedParamGetters + */ + +/** + * @typedef {Readonly<{ + * debtMint: ZCFMint<'nat'>, + * collateralBrand: Brand<'nat'>, + * collateralUnit: Amount<'nat'>, + * descriptionScope: string, + * startTimeStamp: Timestamp, + * storageNode: StorageNode, + * }>} HeldParams + */ + +/** + * @typedef {{ + * assetTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, + * debtBrand: Brand<'nat'>, + * liquidatingVaults: SetStore, + * metricsTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, + * poolIncrementSeat: ZCFSeat, + * retainedCollateralSeat: ZCFSeat, + * unsettledVaults: MapStore, + * }} ImmutableState + */ + +/** + * @typedef {{ + * compoundedInterest: Ratio, + * latestInterestUpdate: Timestamp, + * numLiquidationsCompleted: number, + * numLiquidationsAborted: number, + * totalCollateral: Amount<'nat'>, + * totalCollateralSold: Amount<'nat'>, + * totalDebt: Amount<'nat'>, + * liquidatingCollateral: Amount<'nat'>, + * liquidatingDebt: Amount<'nat'>, + * totalOverageReceived: Amount<'nat'>, + * totalProceedsReceived: Amount<'nat'>, + * totalShortfallReceived: Amount<'nat'>, + * vaultCounter: number, + * lockedQuote: PriceQuote | undefined, + * }} MutableState + */ + +/** + * @type {(brand: Brand) => { + * prioritizedVaults: ReturnType, + * storedQuotesNotifier: import('@agoric/notifier').StoredNotifier, + * storedCollateralQuote: PriceQuote, + * }} + */ +// any b/c will be filled after start() +const collateralEphemera = makeEphemeraProvider(() => /** @type {any} */ ({})); + +/** + * @param {import('@agoric/ertp').Baggage} baggage + * @param {{ + * zcf: import('./vaultFactory.js').VaultFactoryZCF, + * marshaller: ERef, + * makeRecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit, + * makeERecorderKit: import('@agoric/zoe/src/contractSupport/recorder.js').MakeERecorderKit, + * factoryPowers: import('./vaultDirector.js').FactoryPowersFacet, + * }} powers + */ +export const prepareVaultManagerKit = ( + baggage, + { zcf, marshaller, makeRecorderKit, factoryPowers }, +) => { + const { priceAuthority, timerService, reservePublicFacet } = zcf.getTerms(); + + const makeVault = prepareVault(baggage, makeRecorderKit, zcf); + + /** + * @param {HeldParams & { metricsStorageNode: StorageNode }} params + * @returns {HeldParams & ImmutableState & MutableState} + */ + const initState = params => { + const { + debtMint, + collateralBrand, + metricsStorageNode, + startTimeStamp, + storageNode, + } = params; + const debtBrand = debtMint.getIssuerRecord().brand; + + /** @type {ImmutableState} */ + const immutable = { + debtBrand, + poolIncrementSeat: zcf.makeEmptySeatKit().zcfSeat, + + /** + * Vaults that have been sent for liquidation. When we get proceeds (or lack + * thereof) back from the liquidator, we will allocate them among the vaults. + * + * @type {SetStore} + */ + liquidatingVaults: makeScalarBigSetStore('liquidatingVaults', { + durable: true, + }), + + assetTopicKit: makeRecorderKit(storageNode), + + metricsTopicKit: makeRecorderKit(metricsStorageNode), + + // TODO(#7074) not used while liquidation is disabled. Reinstate with #7074 + retainedCollateralSeat: zcf.makeEmptySeatKit().zcfSeat, + + unsettledVaults: makeScalarBigMapStore('orderedVaultStore', { + durable: true, + }), + }; + + const zeroCollateral = AmountMath.makeEmpty(collateralBrand, 'nat'); + const zeroDebt = AmountMath.makeEmpty(debtBrand, 'nat'); + + return harden({ + ...params, + ...immutable, + compoundedInterest: makeRatio(100n, debtBrand), // starts at 1.0, no interest + latestInterestUpdate: startTimeStamp, + numLiquidationsCompleted: 0, + numLiquidationsAborted: 0, + totalCollateral: zeroCollateral, + totalCollateralSold: zeroCollateral, + totalDebt: zeroDebt, + liquidatingCollateral: zeroCollateral, + liquidatingDebt: zeroDebt, + totalOverageReceived: zeroDebt, + totalProceedsReceived: zeroDebt, + totalShortfallReceived: zeroDebt, + vaultCounter: 0, + lockedQuote: undefined, + }); + }; + + const makeVaultManagerKitInternal = prepareExoClassKit( + baggage, + 'VaultManagerKit', + { + collateral: M.interface('collateral', { + makeVaultInvitation: M.call().returns(M.promise()), + getPublicTopics: M.call().returns(TopicsRecordShape), + getQuotes: M.call().returns(NotifierShape), + getCompoundedInterest: M.call().returns(RatioShape), + }), + helper: M.interface( + 'helper', + // not exposed so sloppy okay + {}, + { sloppy: true }, + ), + manager: M.interface('manager', { + getGovernedParams: M.call().returns(M.remotable('governedParams')), + maxDebtFor: M.call(AmountShape).returns(AmountShape), + mintAndTransfer: M.call( + SeatShape, + AmountShape, + AmountShape, + M.arrayOf(TransferPartShape), + ).returns(), + burn: M.call(AmountShape, SeatShape).returns(), + getAssetSubscriber: M.call().returns(SubscriberShape), + getCollateralBrand: M.call().returns(BrandShape), + getDebtBrand: M.call().returns(BrandShape), + getCompoundedInterest: M.call().returns(RatioShape), + scopeDescription: M.call(M.string()).returns(M.string()), + handleBalanceChange: M.call( + AmountShape, + AmountShape, + M.string(), + M.string(), + M.remotable('vault'), + ).returns(), + }), + self: M.interface('self', { + getGovernedParams: M.call().returns(M.remotable('governedParams')), + makeVaultKit: M.call(SeatShape).returns(M.promise()), + getCollateralQuote: M.call().returns(PriceQuoteShape), + getPublicFacet: M.call().returns(M.remotable('publicFacet')), + lockOraclePrices: M.call().returns(PriceQuoteShape), + liquidateVaults: M.call(AuctionPFShape).returns(M.promise()), + }), + }, + initState, + { + collateral: { + makeVaultInvitation() { + const { facets } = this; + const { collateralBrand, debtBrand } = this.state; + return zcf.makeInvitation( + seat => this.facets.self.makeVaultKit(seat), + facets.manager.scopeDescription('MakeVault'), + undefined, + M.splitRecord({ + give: { + Collateral: makeNatAmountShape(collateralBrand), + }, + want: { + Minted: makeNatAmountShape(debtBrand), + }, + }), + ); + }, + getQuotes() { + const ephemera = collateralEphemera(this.state.collateralBrand); + return ephemera.storedQuotesNotifier; + }, + getCompoundedInterest() { + return this.state.compoundedInterest; + }, + getPublicTopics() { + const { assetTopicKit, metricsTopicKit } = this.state; + return harden({ + asset: makeRecorderTopic( + 'State of the assets managed', + assetTopicKit, + ), + metrics: makeRecorderTopic( + 'Vault Factory metrics', + metricsTopicKit, + ), + }); + }, + }, + + // Some of these could go in closures but are kept on a facet anticipating future durability options. + helper: { + /** + * Start non-durable processes (or restart if needed after vat restart) + */ + start() { + const { state, facets } = this; + trace(state.collateralBrand, 'helper.start()', state.vaultCounter); + const { + collateralBrand, + collateralUnit, + debtBrand, + storageNode, + unsettledVaults, + } = state; + + const ephemera = collateralEphemera(collateralBrand); + ephemera.prioritizedVaults = makePrioritizedVaults(unsettledVaults); + + trace('helper.start() making periodNotifier'); + const periodNotifier = E(timerService).makeNotifier( + 0n, + factoryPowers + .getGovernedParams(collateralBrand) + .getChargingPeriod(), + ); + + trace('helper.start() starting observe periodNotifier'); + void observeNotifier(periodNotifier, { + updateState: updateTime => + facets.helper + .chargeAllVaults(updateTime) + .catch(e => + console.error('🚨 vaultManager failed to charge interest', e), + ), + fail: reason => { + zcf.shutdownWithFailure( + assert.error(X`Unable to continue without a timer: ${reason}`), + ); + }, + finish: done => { + zcf.shutdownWithFailure( + assert.error(X`Unable to continue without a timer: ${done}`), + ); + }, + }); + + trace('helper.start() making quoteNotifier from', priceAuthority); + const quoteNotifier = E(priceAuthority).makeQuoteNotifier( + collateralUnit, + debtBrand, + ); + ephemera.storedQuotesNotifier = makeStoredNotifier( + quoteNotifier, + E(storageNode).makeChildNode('quotes'), + marshaller, + ); + trace('helper.start() awaiting observe storedQuotesNotifier'); + // NB: upon restart, there may not be a price for a while. If manager + // operations are permitted, ones the depend on price information will + // throw. See https://github.com/Agoric/agoric-sdk/issues/4317 + void observeNotifier(quoteNotifier, { + updateState(value) { + trace('vaultManager got new collateral quote', value); + ephemera.storedCollateralQuote = value; + }, + fail(reason) { + console.error('quoteNotifier failed to iterate', reason); + }, + }); + trace('helper.start() done'); + }, + /** + * @param {Timestamp} updateTime + */ + async chargeAllVaults(updateTime) { + const { state, facets } = this; + const { collateralBrand, debtMint, poolIncrementSeat } = state; + trace(collateralBrand, 'chargeAllVaults', { + updateTime, + }); + + const interestRate = factoryPowers + .getGovernedParams(collateralBrand) + .getInterestRate(); + + // Update state with the results of charging interest + + const changes = chargeInterest( + { + mint: debtMint, + mintAndTransferWithFee: factoryPowers.mintAndTransfer, + poolIncrementSeat, + seatAllocationKeyword: 'Minted', + }, + { + interestRate, + chargingPeriod: factoryPowers + .getGovernedParams(collateralBrand) + .getChargingPeriod(), + recordingPeriod: factoryPowers + .getGovernedParams(collateralBrand) + .getRecordingPeriod(), + }, + { + latestInterestUpdate: state.latestInterestUpdate, + compoundedInterest: state.compoundedInterest, + totalDebt: state.totalDebt, + }, + updateTime, + ); + + state.compoundedInterest = changes.compoundedInterest; + state.latestInterestUpdate = changes.latestInterestUpdate; + state.totalDebt = changes.totalDebt; + + return facets.helper.assetNotify(); + }, + assetNotify() { + const { state } = this; + const { collateralBrand, assetTopicKit } = state; + const interestRate = factoryPowers + .getGovernedParams(collateralBrand) + .getInterestRate(); + /** @type {AssetState} */ + const payload = harden({ + compoundedInterest: state.compoundedInterest, + interestRate, + latestInterestUpdate: state.latestInterestUpdate, + }); + return assetTopicKit.recorder.write(payload); + }, + burnToCoverDebt(debt, proceeds, seat) { + const { state } = this; + + if (AmountMath.isGTE(proceeds, debt)) { + factoryPowers.burnDebt(debt, seat); + state.totalDebt = AmountMath.subtract(state.totalDebt, debt); + } else { + factoryPowers.burnDebt(proceeds, seat); + state.totalDebt = AmountMath.subtract(state.totalDebt, proceeds); + } + + state.totalProceedsReceived = AmountMath.add( + state.totalProceedsReceived, + proceeds, + ); + }, + sendToReserve(penalty, seat, seatKeyword = 'Collateral') { + const invitation = + E(reservePublicFacet).makeAddCollateralInvitation(); + trace('Sending to reserve: ', penalty); + + // don't wait for response + void E.when(invitation, invite => { + const proposal = { give: { Collateral: penalty } }; + return offerTo( + zcf, + invite, + { [seatKeyword]: 'Collateral' }, + proposal, + seat, + ); + }).catch(reason => { + console.error('sendToReserve failed', reason); + }); + }, + markLiquidating(debt, collateral) { + const { state } = this; + + state.liquidatingCollateral = AmountMath.add( + state.liquidatingCollateral, + collateral, + ); + + state.liquidatingDebt = AmountMath.add(state.liquidatingDebt, debt); + }, + /** + * + * @param {Amount<'nat'>} debt + * @param {Amount<'nat'>} collateral + * @param {Amount<'nat'>} overage + * @param {Amount<'nat'>} shortfall + */ + markDoneLiquidating(debt, collateral, overage, shortfall) { + const { state } = this; + + // update liquidation state + + state.liquidatingCollateral = AmountMath.subtract( + state.liquidatingCollateral, + collateral, + ); + state.liquidatingDebt = AmountMath.subtract( + state.liquidatingDebt, + debt, + ); + + // record shortfall and proceeds + + // cumulative values + state.totalOverageReceived = AmountMath.add( + state.totalOverageReceived, + overage, + ); + state.totalShortfallReceived = AmountMath.add( + state.totalShortfallReceived, + shortfall, + ); + state.totalDebt = AmountMath.subtract(state.totalDebt, shortfall); + + E.when( + factoryPowers.getShortfallReporter(), + reporter => E(reporter).increaseLiquidationShortfall(shortfall), + err => + console.error( + '🛠️ getShortfallReporter() failed during liquidation; repair by updating governance', + err, + ), + ).catch(err => { + console.error('🚨 failed to report liquidation shortfall', err); + }); + }, + writeMetrics() { + const { state } = this; + const { collateralBrand, retainedCollateralSeat, metricsTopicKit } = + state; + const { prioritizedVaults } = collateralEphemera(collateralBrand); + + const retainedCollateral = + retainedCollateralSeat.getCurrentAllocation()?.Collateral ?? + AmountMath.makeEmpty(collateralBrand, 'nat'); + + const quote = state.lockedQuote; + const lockedQuoteRatio = quote + ? quoteAsRatio(quote.quoteAmount.value[0]) + : null; + + /** @type {MetricsNotification} */ + const payload = harden({ + numActiveVaults: prioritizedVaults.getCount(), + numLiquidatingVaults: state.liquidatingVaults.getSize(), + totalCollateral: state.totalCollateral, + totalDebt: state.totalDebt, + retainedCollateral, + + numLiquidationsCompleted: state.numLiquidationsCompleted, + numLiquidationsAborted: state.numLiquidationsAborted, + totalCollateralSold: state.totalCollateralSold, + liquidatingCollateral: state.liquidatingCollateral, + liquidatingDebt: state.liquidatingDebt, + totalOverageReceived: state.totalOverageReceived, + totalProceedsReceived: state.totalProceedsReceived, + totalShortfallReceived: state.totalShortfallReceived, + lockedQuote: lockedQuoteRatio, + }); + + return E(metricsTopicKit.recorder).write(payload); + }, + + /** + * This is designed to tolerate an incomplete plan, in case calculateDistributionPlan encounters + * an error during its calculation. We don't have a way to induce such errors in CI so we've + * done so manually in dev and verified this function recovers as expected. + * + * @param {AmountKeywordRecord} proceeds + * @param {Amount<'nat'>} totalDebt + * @param {Pick} oraclePriceAtStart + * @param {MapStore, debtAmount: Amount<'nat'>}>} vaultData + * @param {Amount<'nat'>} totalCollateral + */ + planProceedsDistribution( + proceeds, + totalDebt, + oraclePriceAtStart, + vaultData, + totalCollateral, + ) { + const { state, facets } = this; + + const { Collateral: collateralProceeds } = proceeds; + /** @type {Amount<'nat'>} */ + const collateralSold = AmountMath.subtract( + totalCollateral, + collateralProceeds, + ); + state.totalCollateralSold = AmountMath.add( + state.totalCollateralSold, + collateralSold, + ); + + const penaltyRate = facets.self + .getGovernedParams() + .getLiquidationPenalty(); + const bestToWorst = [...vaultData.entries()].reverse(); + + // unzip the entry tuples + const vaultsInPlan = /** @type {Vault[]} */ ([]); + const vaultsBalances = + /** @type {import('./proceeds.js').VaultBalances[]} */ ([]); + for (const [vault, balances] of bestToWorst) { + vaultsInPlan.push(vault); + vaultsBalances.push({ + collateral: balances.collateralAmount, + // if interest accrued during sale, the current debt will be higher + presaleDebt: balances.debtAmount, + currentDebt: vault.getCurrentDebt(), + }); + } + harden(vaultsInPlan); + harden(vaultsBalances); + + const plan = calculateDistributionPlan({ + proceeds, + totalDebt, + totalCollateral, + oraclePriceAtStart: oraclePriceAtStart.quoteAmount.value[0], + vaultsBalances, + penaltyRate, + }); + return { plan, vaultsInPlan }; + }, + + /** + * This is designed to tolerate an incomplete plan, in case calculateDistributionPlan encounters + * an error during its calculation. We don't have a way to induce such errors in CI so we've + * done so manually in dev and verified this function recovers as expected. + * + * @param {object} obj + * @param {import('./proceeds.js').DistributionPlan} obj.plan + * @param {Array} obj.vaultsInPlan + * @param {ZCFSeat} obj.liqSeat + * @param {Amount<'nat'>} obj.totalCollateral + * @param {Amount<'nat'>} obj.totalDebt + * @returns {void} + */ + distributeProceeds({ + plan, + vaultsInPlan, + liqSeat, + totalCollateral, + totalDebt, + }) { + const { state, facets } = this; + // Putting all the rearrangements after the loop ensures that errors + // in the calculations don't result in paying back some vaults and + // leaving others hanging. + if (plan.transfersToVault.length > 0) { + const transfers = plan.transfersToVault.map( + ([vaultIndex, amounts]) => + /** @type {import('@agoric/zoe/src/contractSupport/atomicTransfer.js').TransferPart} */ ([ + liqSeat, + vaultsInPlan[vaultIndex].getVaultSeat(), + amounts, + ]), + ); + atomicRearrange(zcf, harden(transfers)); + } + + const { prioritizedVaults } = collateralEphemera( + totalCollateral.brand, + ); + state.numLiquidationsAborted += plan.vaultsToReinstate.length; + for (const vaultIndex of plan.vaultsToReinstate) { + const vault = vaultsInPlan[vaultIndex]; + const vaultId = vault.abortLiquidation(); + prioritizedVaults.addVault(vaultId, vault); + state.liquidatingVaults.delete(vault); + } + + if (!AmountMath.isEmpty(plan.phantomDebt)) { + state.totalDebt = AmountMath.subtract( + state.totalDebt, + plan.phantomDebt, + ); + } + + facets.helper.burnToCoverDebt( + plan.debtToBurn, + plan.mintedProceeds, + liqSeat, + ); + if (!AmountMath.isEmpty(plan.mintedForReserve)) { + facets.helper.sendToReserve( + plan.mintedForReserve, + liqSeat, + 'Minted', + ); + } + + // send all that's left in the seat + const collateralInLiqSeat = liqSeat.getCurrentAllocation().Collateral; + if (!AmountMath.isEmpty(collateralInLiqSeat)) { + facets.helper.sendToReserve(collateralInLiqSeat, liqSeat); + } + // if it didn't match what was expected, report + if (!AmountMath.isEqual(collateralInLiqSeat, plan.collatRemaining)) { + console.error( + `⚠️ Excess collateral remaining sent to reserve. Expected ${q( + plan.collatRemaining, + )}, sent ${q(collateralInLiqSeat)}`, + ); + } + + // 'totalCollateralSold' is only for this liquidation event + // 'state.totalCollateralSold' represents all active vaults + const actualCollateralSold = plan.actualCollateralSold; + state.totalCollateral = AmountMath.isEmpty(actualCollateralSold) + ? AmountMath.subtract(state.totalCollateral, totalCollateral) + : AmountMath.subtract(state.totalCollateral, actualCollateralSold); + + facets.helper.markDoneLiquidating( + totalDebt, + totalCollateral, + plan.overage, + plan.shortfallToReserve, + ); + + // liqSeat should be empty at this point, except that funds are sent + // asynchronously to the reserve. + }, + }, + + manager: { + getGovernedParams() { + const { collateralBrand } = this.state; + return factoryPowers.getGovernedParams(collateralBrand); + }, + + /** + * Look up the most recent price authority price to determine the max + * debt this manager config will allow for the collateral. + * + * @param {Amount<'nat'>} collateralAmount + */ + maxDebtFor(collateralAmount) { + const { collateralBrand } = this.state; + const { storedCollateralQuote } = collateralEphemera(collateralBrand); + if (!storedCollateralQuote) + throw Fail`maxDebtFor called before a collateral quote was available`; + // use the lower price to prevent vault adjustments that put them imminently underwater + const collateralPrice = minimumPrice( + storedCollateralQuote, + this.state.lockedQuote, + ); + const collatlVal = ceilMultiplyBy(collateralAmount, collateralPrice); + const minimumCollateralization = calculateMinimumCollateralization( + factoryPowers + .getGovernedParams(collateralBrand) + .getLiquidationMargin(), + factoryPowers + .getGovernedParams(collateralBrand) + .getLiquidationPadding(), + ); + // floorDivide because we want the debt ceiling lower + return floorDivideBy(collatlVal, minimumCollateralization); + }, + /** @type {MintAndTransfer} */ + mintAndTransfer(mintReceiver, toMint, fee, transfers) { + const { state } = this; + const { collateralBrand, totalDebt } = state; + + checkDebtLimit( + factoryPowers.getGovernedParams(collateralBrand).getDebtLimit(), + totalDebt, + toMint, + ); + factoryPowers.mintAndTransfer(mintReceiver, toMint, fee, transfers); + }, + /** + * @param {Amount<'nat'>} toBurn + * @param {ZCFSeat} seat + */ + burn(toBurn, seat) { + const { state } = this; + const { collateralBrand } = this.state; + + trace(collateralBrand, 'burn', { + toBurn, + totalDebt: state.totalDebt, + }); + factoryPowers.burnDebt(toBurn, seat); + }, + getAssetSubscriber() { + return this.state.assetTopicKit.subscriber; + }, + getCollateralBrand() { + const { collateralBrand } = this.state; + return collateralBrand; + }, + getDebtBrand() { + const { debtBrand } = this.state; + return debtBrand; + }, + /** + * Prepend with an identifier of this vault manager + * + * @param {string} base + */ + scopeDescription(base) { + const { descriptionScope } = this.state; + return `${descriptionScope}: ${base}`; + }, + /** + * coefficient on existing debt to calculate new debt + */ + getCompoundedInterest() { + return this.state.compoundedInterest; + }, + /** + * Called by a vault when its balances change. + * + * @param {NormalizedDebt} oldDebtNormalized + * @param {Amount<'nat'>} oldCollateral + * @param {VaultId} vaultId + * @param {import('./vault.js').VaultPhase} vaultPhase at the end of whatever change updated balances + * @param {Vault} vault + * @returns {void} + */ + handleBalanceChange( + oldDebtNormalized, + oldCollateral, + vaultId, + vaultPhase, + vault, + ) { + const { state, facets } = this; + + // the manager holds only vaults that can accrue interest or be liquidated; + // i.e. vaults that have debt. The one exception is at the outset when + // a vault has been added to the manager but not yet accounted for. + const settled = + AmountMath.isEmpty(oldDebtNormalized) && + vaultPhase !== Phase.ACTIVE; + + trace('handleBalanceChange', { + oldDebtNormalized, + oldCollateral, + vaultId, + vaultPhase, + vault, + settled, + }); + + const { prioritizedVaults } = collateralEphemera( + state.collateralBrand, + ); + if (settled) { + assert( + !prioritizedVaults.hasVaultByAttributes( + oldDebtNormalized, + oldCollateral, + vaultId, + ), + 'Settled vaults must not be retained in storage', + ); + return; + } + + const isNew = AmountMath.isEmpty(oldDebtNormalized); + trace(state.collateralBrand, { isNew }); + if (!isNew) { + // its position in the queue is no longer valid + + const vaultInStore = prioritizedVaults.removeVaultByAttributes( + oldDebtNormalized, + oldCollateral, + vaultId, + ); + assert( + vault === vaultInStore, + 'handleBalanceChange for two different vaults', + ); + trace('removed', vault, vaultId); + } + + // replace in queue, but only if it can accrue interest or be liquidated (i.e. has debt). + // getCurrentDebt() would also work (0x = 0) but require more computation. + if (!AmountMath.isEmpty(vault.getNormalizedDebt())) { + prioritizedVaults.addVault(vaultId, vault); + } + + // total += vault's delta (post — pre) + state.totalCollateral = AmountMath.subtract( + AmountMath.add(state.totalCollateral, vault.getCollateralAmount()), + oldCollateral, + ); + state.totalDebt = AmountMath.subtract( + AmountMath.add(state.totalDebt, vault.getCurrentDebt()), + oldDebtNormalized, + ); + void facets.helper.writeMetrics(); + }, + }, + self: { + getGovernedParams() { + const { collateralBrand } = this.state; + return factoryPowers.getGovernedParams(collateralBrand); + }, + + /** + * @param {ZCFSeat} seat + */ + async makeVaultKit(seat) { + const { + state, + facets: { manager }, + } = this; + trace(state.collateralBrand, 'makeVaultKit'); + const { storageNode } = this.state; + assert(marshaller, 'makeVaultKit missing marshaller'); + assert(storageNode, 'makeVaultKit missing storageNode'); + assert(zcf, 'makeVaultKit missing zcf'); + + const vaultId = String(state.vaultCounter); + + // must be a presence to be stored in vault state + const vaultStorageNode = await E( + E(storageNode).makeChildNode(`vaults`), + ).makeChildNode(`vault${vaultId}`); + + const { self: vault } = makeVault(manager, vaultId, vaultStorageNode); + trace(state.collateralBrand, 'makeVaultKit made vault', vault); + + try { + // TODO `await` is allowed until the above ordering is fixed + // eslint-disable-next-line @jessie.js/no-nested-await + const vaultKit = await vault.initVaultKit(seat, vaultStorageNode); + // initVaultKit calls back to handleBalanceChange() which will add the + // vault to prioritizedVaults + + // initVaultKit doesn't write to the storage node until it's returning + // so if it returned then we know the node key was consumed + state.vaultCounter += 1; + + return vaultKit; + } catch (err) { + console.error( + 'attempting recovery after initVaultKit failure', + err, + ); + // ??? do we still need this cleanup? it won't get into the store unless it has collateral, + // which should qualify it to be in the store. If we drop this catch then the nested await + // for `vault.initVaultKit()` goes away. + + // remove it from the store if it got in + /** @type {NormalizedDebt} */ + // @ts-expect-error cast + const normalizedDebt = AmountMath.makeEmpty(state.debtBrand); + const collateralPre = seat.getCurrentAllocation().Collateral; + const { prioritizedVaults } = collateralEphemera( + state.collateralBrand, + ); + try { + prioritizedVaults.removeVaultByAttributes( + normalizedDebt, + collateralPre, + vaultId, + ); + console.warn( + 'removed vault', + vaultId, + 'after initVaultKit failure', + ); + } catch { + console.info( + 'vault', + vaultId, + 'never stored during initVaultKit failure', + ); + } + throw err; + } finally { + if (!seat.hasExited()) { + seat.exit(); + } + } + }, + + getCollateralQuote() { + const { storedCollateralQuote } = collateralEphemera( + this.state.collateralBrand, + ); + if (!storedCollateralQuote) + throw Fail`getCollateralQuote called before a collateral quote was available`; + return storedCollateralQuote; + }, + + getPublicFacet() { + return this.facets.collateral; + }, + + lockOraclePrices() { + const { state, facets } = this; + const { storedCollateralQuote } = collateralEphemera( + state.collateralBrand, + ); + if (!storedCollateralQuote) + throw Fail`lockOraclePrices called before a collateral quote was available`; + trace( + `lockPrice`, + getAmountIn(storedCollateralQuote), + getAmountOut(storedCollateralQuote), + ); + + state.lockedQuote = storedCollateralQuote; + facets.helper.writeMetrics(); + return storedCollateralQuote; + }, + /** + * @param {AuctioneerPublicFacet} auctionPF + */ + async liquidateVaults(auctionPF) { + const { state, facets } = this; + const { self, helper } = facets; + const { + collateralBrand, + compoundedInterest, + debtBrand, + liquidatingVaults, + lockedQuote, + } = state; + trace(collateralBrand, 'considering liquidation'); + + const { prioritizedVaults } = collateralEphemera(collateralBrand); + assert(factoryPowers && prioritizedVaults && zcf); + lockedQuote || + Fail`Must have locked a quote before liquidating vaults.`; + assert(lockedQuote); // redundant with previous line + + const liqMargin = self.getGovernedParams().getLiquidationMargin(); + + // totals *among* vaults being liquidated + const { totalDebt, totalCollateral, vaultData, liqSeat } = + getLiquidatableVaults( + zcf, + { + quote: lockedQuote, + interest: compoundedInterest, + margin: liqMargin, + }, + prioritizedVaults, + liquidatingVaults, + debtBrand, + collateralBrand, + ); + // reset lockedQuote after we've used it for the liquidation decision + state.lockedQuote = undefined; + + if (vaultData.getSize() === 0) { + return; + } + trace( + ' Found vaults to liquidate', + liquidatingVaults.getSize(), + totalCollateral, + ); + + helper.markLiquidating(totalDebt, totalCollateral); + void helper.writeMetrics(); + + const { userSeatPromise, deposited } = await E.when( + E(auctionPF).makeDepositInvitation(), + depositInvitation => + offerTo( + zcf, + depositInvitation, + harden({ Minted: 'Bid' }), + harden({ give: { Collateral: totalCollateral } }), + liqSeat, + liqSeat, + { goal: totalDebt }, + ), + ); + + // This is expected to wait for the duration of the auction, which + // is controlled by the auction parameters startFrequency, clockStep, + // and the difference between startingRate and lowestRate. + const [proceeds] = await Promise.all([deposited, userSeatPromise]); + + const { storedCollateralQuote } = collateralEphemera( + this.state.collateralBrand, + ); + + trace(`LiqV after long wait`, proceeds); + try { + const { plan, vaultsInPlan } = helper.planProceedsDistribution( + proceeds, + totalDebt, + storedCollateralQuote, + vaultData, + totalCollateral, + ); + trace('PLAN', plan); + // distributeProceeds may reconstitute vaults, removing them from liquidatingVaults + helper.distributeProceeds({ + liqSeat, + plan, + totalCollateral, + totalDebt, + vaultsInPlan, + }); + } catch (err) { + console.error('🚨 Error distributing proceeds:', err); + } + + // for all non-reconstituted vaults, transition to 'liquidated' state + state.numLiquidationsCompleted += liquidatingVaults.getSize(); + for (const vault of liquidatingVaults.values()) { + vault.liquidated(); + liquidatingVaults.delete(vault); + } + + await facets.helper.writeMetrics(); + }, + }, + }, + + { + finish: ({ state, facets: { helper } }) => { + helper.start(); + void state.assetTopicKit.recorder.write( + harden({ + compoundedInterest: state.compoundedInterest, + interestRate: factoryPowers + .getGovernedParams(state.collateralBrand) + .getInterestRate(), + latestInterestUpdate: state.latestInterestUpdate, + }), + ); + + // push initial state of metrics + void helper.writeMetrics(); + }, + }, + ); + + /** @param {Omit[0], 'metricsStorageNode'>} externalParams */ + const makeVaultManagerKit = async externalParams => { + const metricsStorageNode = await E( + externalParams.storageNode, + ).makeChildNode('metrics'); + return makeVaultManagerKitInternal({ + ...externalParams, + metricsStorageNode, + }); + }; + return makeVaultManagerKit; +}; + +/** + * @typedef {Awaited>>} VaultManagerKit + */ +/** + * @typedef {VaultManagerKit['self']} VaultManager + * Each VaultManager manages a single collateral type. + * + * It manages some number of outstanding debt positions, each called a Vault, + * for which the collateral is provided in exchange for borrowed Minted. + */ +/** @typedef {VaultManagerKit['collateral']} CollateralManager */ + +/** + * Support restarting kits from baggage and mutating the array holding them + * + * @param {import('@agoric/vat-data').Baggage} baggage + */ +export const provideAndStartVaultManagerKits = baggage => { + trace('provideAndStartVaultManagerKits start'); + const key = 'vaultManagerKits'; + + const noKits = /** @type {VaultManagerKit[]} */ (harden([])); + + for (const kit of provide(baggage, key, () => noKits)) { + kit.helper.start(); + } + + trace('provideAndStartVaultManagerKits returning'); + return { + /** @type {(kit: VaultManagerKit) => void} */ + add: kit => { + appendToStoredArray(baggage, key, kit); + }, + /** @type {(index: number) => VaultManagerKit} */ + get: index => { + const kits = baggage.get(key); + index < kits.length || Fail`no VaultManagerKit at index ${index}`; + return kits[index]; + }, + length: () => baggage.get(key).length, + }; +}; +harden(provideAndStartVaultManagerKits); From 163995cfffda41b161c813fd0cc5525057374065 Mon Sep 17 00:00:00 2001 From: JorgeLopes-BytePitch Date: Fri, 23 Feb 2024 17:42:16 +0000 Subject: [PATCH 10/12] feat(liquidationVisibility): add bootstrap tests for back compatibility (WiP) --- packages/inter-protocol/package.json | 1 + .../src/vaultFactory/vaultManager.js | 29 +- .../init-old-vaultFactory.js | 203 +++++++ .../liquidation-test-utils.js | 562 ++++++++++++++++++ .../test-liquidation-visibility.js.snap | Bin 0 -> 1397 bytes .../test-liquidation-compability.js | 105 ++++ .../vaults-liquidation-config.json | 228 +++++++ 7 files changed, 1120 insertions(+), 8 deletions(-) create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/init-old-vaultFactory.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/liquidation-test-utils.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/snapshots/test-liquidation-visibility.js.snap create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/test-liquidation-compability.js create mode 100644 packages/vats/test/bootstrapTests/liquidationVisibility/vaults-liquidation-config.json diff --git a/packages/inter-protocol/package.json b/packages/inter-protocol/package.json index d7dc2ef988b..febddd5f8d3 100644 --- a/packages/inter-protocol/package.json +++ b/packages/inter-protocol/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "yarn build:bundles", "build:bundles": "node ./scripts/build-bundles.js", + "build:upgrade-vaults-proposal": "agoric run scripts/liquidation-visibility-upgrade.js", "test": "ava", "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", "test:xs": "exit 0", diff --git a/packages/inter-protocol/src/vaultFactory/vaultManager.js b/packages/inter-protocol/src/vaultFactory/vaultManager.js index ff8a6d48bfe..94f0a7b29be 100644 --- a/packages/inter-protocol/src/vaultFactory/vaultManager.js +++ b/packages/inter-protocol/src/vaultFactory/vaultManager.js @@ -129,7 +129,6 @@ const trace = makeTracer('VM'); * @typedef {{ * assetTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, * debtBrand: Brand<'nat'>, - * liquidationsStorageNode: StorageNode * liquidatingVaults: SetStore, * metricsTopicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit, * poolIncrementSeat: ZCFSeat, @@ -226,7 +225,6 @@ export const prepareVaultManagerKit = ( debtMint, collateralBrand, metricsStorageNode, - liquidationsStorageNode, startTimeStamp, storageNode, } = params; @@ -236,7 +234,6 @@ export const prepareVaultManagerKit = ( const immutable = { debtBrand, poolIncrementSeat: zcf.makeEmptySeatKit().zcfSeat, - liquidationsStorageNode, /** * Vaults that have been sent for liquidation. When we get proceeds (or lack * thereof) back from the liquidator, we will allocate them among the vaults. @@ -279,6 +276,7 @@ export const prepareVaultManagerKit = ( totalShortfallReceived: zeroDebt, vaultCounter: 0, lockedQuote: undefined, + liquidationsStorageNode: undefined, }); }; @@ -446,6 +444,19 @@ export const prepareVaultManagerKit = ( }); trace('helper.start() done'); }, + provideLiquidationStorageNode() { + const { state } = this; + + console.log('LOG: provideLiquidationStorageNode'); + + const liquidationNodeP = E(state.storageNode).makeChildNode( + 'liquidations', + ); + + E.when(liquidationNodeP, liquidationNode => { + state.liquidationsStorageNode = liquidationNode; + }); + }, /** * @param {Timestamp} updateTime */ @@ -742,6 +753,8 @@ export const prepareVaultManagerKit = ( state: { liquidationsStorageNode }, } = this; + const { state } = this; + const timestampStorageNode = E(liquidationsStorageNode).makeChildNode( `${timestamp.absValue}`, ); @@ -1379,6 +1392,7 @@ export const prepareVaultManagerKit = ( { finish: ({ state, facets: { helper } }) => { + console.log('LOG: FINISHED'); helper.start(); void state.assetTopicKit.recorder.write( harden({ @@ -1403,15 +1417,13 @@ export const prepareVaultManagerKit = ( * >} externalParams */ const makeVaultManagerKit = async externalParams => { - const [metricsStorageNode, liquidationsStorageNode] = await Promise.all([ - E(externalParams.storageNode).makeChildNode('metrics'), - E(externalParams.storageNode).makeChildNode('liquidations'), - ]); + const metricsStorageNode = await E( + externalParams.storageNode, + ).makeChildNode('metrics'); return makeVaultManagerKitInternal({ ...externalParams, metricsStorageNode, - liquidationsStorageNode, }); }; return makeVaultManagerKit; @@ -1442,6 +1454,7 @@ export const provideAndStartVaultManagerKits = baggage => { for (const kit of provide(baggage, key, () => noKits)) { kit.helper.start(); + kit.helper.provideLiquidationStorageNode(); } trace('provideAndStartVaultManagerKits returning'); diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/init-old-vaultFactory.js b/packages/vats/test/bootstrapTests/liquidationVisibility/init-old-vaultFactory.js new file mode 100644 index 00000000000..cb18de17cbf --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/init-old-vaultFactory.js @@ -0,0 +1,203 @@ +/* global process */ +/** + * @file can be run with `agoric deploy` after a chain is running (depends on chain state) + * Only works with "local" chain and not sim-chain b/c it needs governance votes (n/a on sim-chain). + */ +import { makeHelpers } from '@agoric/deploy-script-support'; +import { objectMap } from '@agoric/internal'; + +import { + getManifestForInterProtocol, + getManifestForEconCommittee, + getManifestForMain, +} from '@agoric/inter-protocol/src/proposals/core-proposal.js'; +import { makeInstallCache } from '@agoric/inter-protocol/src/proposals/utils.js'; + +/** @type {Record>} */ +const installKeyGroups = { + econCommittee: { + contractGovernor: [ + '@agoric/governance/src/contractGovernor.js', + '../../../../governance/bundles/bundle-contractGovernor.js', + ], + committee: [ + '@agoric/governance/src/committee.js', + '../../../../governance/bundles/bundle-committee.js', + ], + binaryVoteCounter: [ + '@agoric/governance/src/binaryVoteCounter.js', + '../../../../governance/bundles/bundle-binaryVoteCounter.js', + ], + }, + main: { + auctioneer: [ + '@agoric/inter-protocol/src/auction/auctioneer.js', + '../../../../inter-protocol/bundles/bundle-auctioneer.js', + ], + vaultFactory: [ + '/Users/jorgelopes/Documents/GitHub/Agoric/sow6/liquidation-visibility/agoric-sdk-liquidation-visibility/packages/vats/test/bootstrapTests/liquidationVisibility/vaultFactory/vaultFactory.js', + '/Users/jorgelopes/Documents/GitHub/Agoric/sow6/liquidation-visibility/agoric-sdk-liquidation-visibility/packages/vats/test/bootstrapTests/liquidationVisibility/bundles/bundle-vaultFactory.js', + ], + feeDistributor: [ + '@agoric/inter-protocol/src/feeDistributor.js', + '../../../../inter-protocol/bundles/bundle-feeDistributor.js', + ], + reserve: [ + '@agoric/inter-protocol/src/reserve/assetReserve.js', + '../../../../inter-protocol/bundles/bundle-reserve.js', + ], + }, +}; + +/** + * @param {object} opts + * @param {(i: I) => R} opts.publishRef + * @param {(m: string, b: string, opts?: any) => I} opts.install + * @param {(f: T) => T} [opts.wrapInstall] + * + * @param {object} [options] + * @param {{ committeeName?: string, committeeSize?: number}} [options.econCommitteeOptions] + * @template I + * @template R + */ +export const committeeProposalBuilder = async ( + { publishRef, install: install0, wrapInstall }, + { econCommitteeOptions } = {}, +) => { + const install = wrapInstall ? wrapInstall(install0) : install0; + + /** @param {Record} group */ + const publishGroup = group => + objectMap(group, ([mod, bundle]) => + publishRef(install(mod, bundle, { persist: true })), + ); + return harden({ + sourceSpec: '@agoric/inter-protocol/src/proposals/core-proposal.js', + getManifestCall: [ + getManifestForEconCommittee.name, + { + econCommitteeOptions, + installKeys: { + ...publishGroup(installKeyGroups.econCommittee), + }, + }, + ], + }); +}; + +/** + * @param {object} opts + * @param {(i: I) => R} opts.publishRef + * @param {(m: string, b: string, opts?: any) => I} opts.install + * @param {(f: T) => T} [opts.wrapInstall] + * + * @template I + * @template R + */ +export const mainProposalBuilder = async ({ + publishRef, + install: install0, + wrapInstall, +}) => { + const { VAULT_FACTORY_CONTROLLER_ADDR } = process.env; + + const install = wrapInstall ? wrapInstall(install0) : install0; + + const persist = true; + /** @param {Record} group */ + const publishGroup = group => + objectMap(group, ([mod, bundle]) => + publishRef(install(mod, bundle, { persist })), + ); + return harden({ + sourceSpec: '@agoric/inter-protocol/src/proposals/core-proposal.js', + getManifestCall: [ + getManifestForMain.name, + { + vaultFactoryControllerAddress: VAULT_FACTORY_CONTROLLER_ADDR, + installKeys: { + ...publishGroup(installKeyGroups.main), + }, + }, + ], + }); +}; + +// Build proposal for sim-chain etc. +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ +export const defaultProposalBuilder = async ( + { publishRef, install }, + options = {}, + { env = process.env } = {}, +) => { + /** @param {string|undefined} s */ + const optBigInt = s => s && BigInt(s); + const { + vaultFactoryControllerAddress = env.VAULT_FACTORY_CONTROLLER_ADDR, + minInitialPoolLiquidity = env.MIN_INITIAL_POOL_LIQUIDITY, + referencedUi, + anchorOptions: { + anchorDenom = env.ANCHOR_DENOM, + anchorDecimalPlaces = '6', + anchorKeyword = 'AUSD', + anchorProposedName = anchorKeyword, + initialPrice = undefined, + } = {}, + econCommitteeOptions: { + committeeSize: econCommitteeSize = env.ECON_COMMITTEE_SIZE || '3', + } = {}, + } = options; + + /** @param {Record} group */ + const publishGroup = group => + objectMap(group, ([mod, bundle]) => publishRef(install(mod, bundle))); + + const anchorOptions = anchorDenom && { + denom: anchorDenom, + decimalPlaces: parseInt(anchorDecimalPlaces, 10), + initialPrice, + keyword: anchorKeyword, + proposedName: anchorProposedName, + }; + + const econCommitteeOptions = { + committeeSize: parseInt(econCommitteeSize, 10), + }; + + return harden({ + sourceSpec: '@agoric/inter-protocol/src/proposals/core-proposal.js', + getManifestCall: [ + getManifestForInterProtocol.name, + { + vaultFactoryControllerAddress, + minInitialPoolLiquidity: optBigInt(minInitialPoolLiquidity), + referencedUi, + anchorOptions, + econCommitteeOptions, + installKeys: { + ...publishGroup(installKeyGroups.econCommittee), + ...publishGroup(installKeyGroups.main), + }, + }, + ], + }); +}; + +export default async (homeP, endowments) => { + const { writeCoreProposal } = await makeHelpers(homeP, endowments); + + const tool = await makeInstallCache(homeP, { + loadBundle: spec => import(spec), + }); + await Promise.all([ + writeCoreProposal('gov-econ-committee', opts => + // @ts-expect-error XXX makeInstallCache types + committeeProposalBuilder({ ...opts, wrapInstall: tool.wrapInstall }), + ), + writeCoreProposal('gov-amm-vaults-etc', opts => + // @ts-expect-error XXX makeInstallCache types + mainProposalBuilder({ ...opts, wrapInstall: tool.wrapInstall }), + ), + ]); + await tool.saveCache(); +}; diff --git a/packages/vats/test/bootstrapTests/liquidationVisibility/liquidation-test-utils.js b/packages/vats/test/bootstrapTests/liquidationVisibility/liquidation-test-utils.js new file mode 100644 index 00000000000..ca261bc00a9 --- /dev/null +++ b/packages/vats/test/bootstrapTests/liquidationVisibility/liquidation-test-utils.js @@ -0,0 +1,562 @@ +/* eslint-disable no-lone-blocks, no-await-in-loop */ +// @ts-check +/** + * @file Bootstrap test vaults liquidation visibility + */ +import * as processAmbient from 'child_process'; +import * as fsAmbient from 'fs'; +import { Fail } from '@agoric/assert'; +import { NonNullish } from '@agoric/assert/src/assert.js'; +import { Offers } from '@agoric/inter-protocol/src/clientSupport.js'; +import { TimeMath } from '@agoric/time'; +import { scale6 } from '../liquidation.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { + SECONDS_PER_HOUR, + SECONDS_PER_MINUTE, +} from '@agoric/inter-protocol/src/proposals/econ-behaviors.js'; +import { makeAgoricNamesRemotesFromFakeStorage } from '../../../tools/board-utils.js'; +import { makeSwingsetTestKit } from '../supports.js'; +import { + makeGovernanceDriver, + makePriceFeedDriver, + makeWalletFactoryDriver, +} from '../drivers.js'; + +const PLATFORM_CONFIG = + '@agoric/vats/test/bootstrapTests/liquidationVisibility/vaults-liquidation-config.json'; + +const DebtLimitValue = scale6(100_000); + +//#region Product spec +const setup = /** @type {const} */ ({ + // Vaults are sorted in the worst debt/col ratio to the best + vaults: [ + { + atom: 15, + ist: 105, + debt: 105.525, + }, + { + atom: 15, + ist: 103, + debt: 103.515, + }, + { + atom: 15, + ist: 100, + debt: 100.5, + }, + ], + bids: [ + { + give: '80IST', + discount: 0.1, + }, + { + give: '90IST', + price: 9.0, + }, + { + give: '150IST', + discount: 0.15, + }, + ], + price: { + starting: 12.34, + trigger: 9.99, + }, + auction: { + start: { + collateral: 45, + debt: 309.54, + }, + end: { + collateral: 9.659301, + debt: 0, + }, + }, +}); + +const outcome = /** @type {const} */ ({ + reserve: { + allocations: { + ATOM: 0.309852, + STARS: 0.309852, + }, + shortfall: 0, + }, + // The order in the setup preserved + vaults: [ + { + locked: 2.846403, + }, + { + locked: 3.0779, + }, + { + locked: 3.425146, + }, + ], +}); +//#endregion + +const placeBids = async ( + t, + collateralBrandKey, + buyerWalletAddress, + base = 0, // number of bids made before +) => { + const { agoricNamesRemotes, walletFactoryDriver, readLatest } = t.context; + + const buyer = await walletFactoryDriver.provideSmartWallet( + buyerWalletAddress, + ); + + await buyer.sendOffer( + Offers.psm.swap( + agoricNamesRemotes, + agoricNamesRemotes.instance['psm-IST-USDC_axl'], + { + offerId: `print-${collateralBrandKey}-ist`, + wantMinted: 1_000, + pair: ['IST', 'USDC_axl'], + }, + ), + ); + + const maxBuy = `10000${collateralBrandKey}`; + + for (let i = 0; i < setup.bids.length; i += 1) { + const offerId = `${collateralBrandKey}-bid${i + 1 + base}`; + // bids are long-lasting offers so we can't wait here for completion + await buyer.sendOfferMaker(Offers.auction.Bid, { + offerId, + ...setup.bids[i], + maxBuy, + }); + t.like(readLatest(`published.wallet.${buyerWalletAddress}`), { + status: { + id: offerId, + result: 'Your bid has been accepted', + payouts: undefined, + }, + }); + } +}; + +const runAuction = async (runUtils, advanceTimeBy) => { + const { EV } = runUtils; + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const { liveAuctionSchedule } = await EV( + auctioneerKit.publicFacet, + ).getSchedules(); + + await advanceTimeBy(3 * Number(liveAuctionSchedule.steps), 'minutes'); + + return liveAuctionSchedule; +}; + +const startAuction = async t => { + const { readLatest, advanceTimeTo } = t.context; + + const scheduleNotification = readLatest('published.auction.schedule'); + + await advanceTimeTo(NonNullish(scheduleNotification.nextStartTime)); +}; + +const addNewVaults = async ({ t, collateralBrandKey, base = 0 }) => { + const { walletFactoryDriver, priceFeedDriver, advanceTimeBy } = t.context; + await advanceTimeBy(1, 'seconds'); + + await priceFeedDriver.setPrice(setup.price.starting); + const minter = await walletFactoryDriver.provideSmartWallet('agoric1minter'); + + for (let i = 0; i < setup.vaults.length; i += 1) { + const offerId = `open-${collateralBrandKey}-vault${base + i}`; + await minter.executeOfferMaker(Offers.vaults.OpenVault, { + offerId, + collateralBrandKey, + wantMinted: setup.vaults[i].ist, + giveCollateral: setup.vaults[i].atom, + }); + t.like(minter.getLatestUpdateRecord(), { + updated: 'offerStatus', + status: { id: offerId, numWantsSatisfied: 1 }, + }); + } + + await placeBids(t, collateralBrandKey, 'agoric1buyer', base); + await priceFeedDriver.setPrice(setup.price.trigger); + await startAuction(t); +}; + +export const checkVisibility = async ({ + t, + managerIndex, + collateralBrandKey, + base = 0, +}) => { + const { readLatest, advanceTimeBy, runUtils } = t.context; + + await addNewVaults({ t, collateralBrandKey, base }); + + const { startTime, startDelay, endTime } = await runAuction( + runUtils, + advanceTimeBy, + ); + + const nominalStart = TimeMath.subtractAbsRel( + startTime.absValue, + startDelay.relValue, + ); + t.log(nominalStart); + + const visibilityPath = `published.vaultFactory.managers.manager${managerIndex}.liquidations.${nominalStart.toString()}`; + const preAuction = readLatest(`${visibilityPath}.vaults.preAuction`); + const postAuction = readLatest(`${visibilityPath}.vaults.postAuction`); + const auctionResult = readLatest(`${visibilityPath}.auctionResult`); + + const expectedPreAuction = []; + for (let i = 0; i < setup.vaults.length; i += 1) { + expectedPreAuction.push([ + `vault${base + i}`, + { + collateralAmount: { value: scale6(setup.vaults[i].atom) }, + debtAmount: { value: scale6(setup.vaults[i].debt) }, + }, + ]); + } + t.like( + Object.fromEntries(preAuction), + Object.fromEntries(expectedPreAuction), + ); + + const expectedPostAuction = []; + // Iterate from the end because we expect the post auction vaults + // in best to worst order. + for (let i = outcome.vaults.length - 1; i >= 0; i -= 1) { + expectedPostAuction.push([ + `vault${base + i}`, + { Collateral: { value: scale6(outcome.vaults[i].locked) } }, + ]); + } + t.like( + Object.fromEntries(postAuction), + Object.fromEntries(expectedPostAuction), + ); + + t.like(auctionResult, { + collateralOffered: { value: scale6(setup.auction.start.collateral) }, + istTarget: { value: scale6(setup.auction.start.debt) }, + collateralForReserve: { value: scale6(outcome.reserve.allocations.ATOM) }, + shortfallToReserve: { value: 0n }, + mintedProceeds: { value: scale6(setup.auction.start.debt) }, + collateralSold: { + value: + scale6(setup.auction.start.collateral) - + scale6(setup.auction.end.collateral), + }, + collateralRemaining: { value: 0n }, + endTime: { absValue: endTime.absValue }, + }); + + t.log('preAuction', preAuction); + t.log('postAuction', postAuction); + t.log('auctionResult', auctionResult); +}; + +export const checkVMChildNodes = async ({ + t, + managerIndex, + collateralBrandKey, + liquidation, + base = 0, +}) => { + const { storage, advanceTimeBy, runUtils } = t.context; + + await addNewVaults({ t, collateralBrandKey, base }); + await runAuction(runUtils, advanceTimeBy); + + const managerPath = `published.vaultFactory.managers.manager${managerIndex}`; + const childNodes = storage.toStorage({ + method: 'children', + args: [`${managerPath}`], + }); + t.log('VaultManager child nodes: ', childNodes); + + let expectedChildNodes = ['governance', 'metrics', 'quotes', 'vaults']; + if (liquidation) { + expectedChildNodes = [...expectedChildNodes, 'liquidations']; + } + + t.deepEqual(childNodes, expectedChildNodes); +}; + +/** + * @param {object} powers + * @param {Pick} powers.childProcess + * @param {typeof import('node:fs/promises')} powers.fs + */ +const makeProposalExtractor = ({ childProcess, fs }) => { + const getPkgPath = (pkg, fileName = '') => + new URL(`../../../../${pkg}/${fileName}`, import.meta.url).pathname; + + const runPackageScript = async (pkg, name, env) => { + console.warn(pkg, 'running package script:', name); + const pkgPath = getPkgPath(pkg); + return childProcess.execFileSync('yarn', ['run', name], { + cwd: pkgPath, + env, + }); + }; + + const loadJSON = async filePath => + harden(JSON.parse(await fs.readFile(filePath, 'utf8'))); + + // XXX parses the output to find the files but could write them to a path that can be traversed + /** @param {string} txt */ + const parseProposalParts = txt => { + const evals = [ + ...txt.matchAll(/swingset-core-eval (?\S+) (?