test-p2p.js
1 // @ts-check 2 3 /* eslint-disable import/order -- https://github.com/endojs/endo/issues/1235 */ 4 import { countKeys, describe } from './prepare-riteway.js'; 5 import path from 'path'; 6 7 import bundleSource from '@endo/bundle-source'; 8 9 import { E } from '@endo/eventual-send'; 10 import { makeFakeVatAdmin } from '@agoric/zoe/tools/fakeVatAdmin.js'; 11 import { AmountMath, makeIssuerKit } from '@agoric/ertp'; 12 import { compose, getHoursInSeconds } from '../src/shared/utils/general.js'; 13 import { Far, passStyleOf } from '@endo/marshal'; 14 import { assert as agAssert } from '@agoric/assert'; 15 import { makeZoeKitForTest } from '@agoric/zoe/tools/setup-zoe.js'; 16 import { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; 17 import { makeScalarBigMapStore } from '@agoric/vat-data'; 18 import { eventLoopIteration } from '../src/tools/eventLoopIteration.js'; 19 import buildManualTimer from '../src/tools/manualTimer.js'; 20 import { makeFakeMarshaller } from '@agoric/notifier/tools/testSupports.js'; 21 22 import { Nat, isNat } from '@endo/nat'; 23 import { setupContract } from './services.js'; 24 import { ONE_DAY } from '../src/shared/utils/time.js'; 25 import { subscriptionTracker } from './test-metrics.js'; 26 const isTruthy = x => !!x; 27 28 const multiplyByEuler = (rate, time) => p => p * Math.pow(Math.E, rate * time); 29 30 const assertIsRemotable = facet => agAssert(passStyleOf(facet) === 'remotable'); 31 32 const filename = new URL(import.meta.url).pathname; 33 const dirname = path.dirname(filename); 34 35 const contractPath = `${dirname}/../src/p2pLendingMarket/p2p.js`; 36 const utilsContractPath = `${dirname}/../src/utilityContract/index.js`; 37 const makeKit = name => makeIssuerKit(name); 38 39 const setupIssuers = (issuers = ['ATOM', 'IST', 'BLD', 'USDC']) => 40 issuers.map(makeKit); 41 42 const issuers = setupIssuers(); 43 const makeAmounts = 44 ({ brand }) => 45 (x = 0n) => 46 AmountMath.make(brand, x); 47 const [atomKit, istKit, bldKit, USDCKit] = issuers; 48 const [atoms, ist, bld, usdc] = issuers.map(makeAmounts); 49 50 const makePurse = ({ issuer }) => issuer.makeEmptyPurse(); 51 52 describe('TimerService API', async assert => { 53 const { contractInstance, zoe, fakeVatAdmin, utilsInstance } = 54 await setupContract(); 55 56 const liquidationWaker = Far('Liquidation Waker', { 57 wake(time) { 58 console.log(`The clock has struck ${time}. Now, we must say goodbye.`); 59 } 60 }); 61 62 /** 63 * @type {import('@agoric/swingset-vat/tools/manual-timer.js').TimerService} 64 */ 65 const timer = buildManualTimer(console.log, 6000n, { 66 timeStep: 5n, 67 eventLoopIteration 68 }); 69 70 const timerClock = await E(timer).getClock(); 71 72 await E(timer).setWakeup(10_000n, liquidationWaker); 73 74 await E(timer).tickN(5n); 75 assert({ 76 given: 'E(timer).getClock()', 77 should: 'create a clock ', 78 actual: await E(timerClock).getTimerBrand(), 79 expected: await E(timer).getTimerBrand() 80 }); 81 82 // const currentTime = await E(timeUtilsFacet).getCurrentTimeMs(); 83 // assert({ 84 // given: 'E(dateFacet).getCurrentTimeMs()', 85 // should: 'return a timestamp', 86 // actual: isNat(currentTime), 87 // expected: true 88 // }); 89 }); 90 91 const callFacet = 92 facet => 93 (method, args = {}) => 94 E(facet)[method](args); 95 96 const facetMethods = { 97 public: { 98 GET_PUBLIC_TOPICS: 'getPublicTopics', 99 SAVE_ISSUERS: 'saveIssuers', 100 GET_ISSUERS: 'getIssuers', 101 UNDERWRITE_LOAN: 'underwriteLoan', 102 GET_FUNDED_LOANS: 'getFundedLoans' 103 } 104 }; 105 106 describe('p2p loan:: sad paths', async (assert, done) => { 107 const KEYWORDS = { 108 IST: 'IST', 109 ATOM: 'ATOM' 110 }; 111 112 const { IST: istKeyword, ATOM: atomKeyword } = KEYWORDS; 113 114 const { contractInstance, zoe, fakeVatAdmin, utilsInstance } = 115 await setupContract(); 116 117 const zoeIssuer = await E(zoe).getInvitationIssuer(); 118 119 const callCreatorFacet = callFacet(contractInstance.creatorFacet); 120 const callPublicFacet = callFacet(contractInstance.publicFacet); 121 122 const { 123 public: { 124 GET_PUBLIC_TOPICS, 125 GET_ISSUERS, 126 GET_FUNDED_LOANS, 127 SAVE_ISSUERS, 128 UNDERWRITE_LOAN 129 } 130 } = facetMethods; 131 132 await assert({ 133 unitLabel: 'E(creatorFacet).saveIssuers()', 134 given: 'an issuer keyword record', 135 should: 'return a success message', 136 actual: await callCreatorFacet(SAVE_ISSUERS, { 137 ATOM: atomKit.issuer, 138 IST: istKit.issuer, 139 BLD: bldKit.issuer, 140 USDC: USDCKit.issuer 141 }), 142 expected: 'Add issuers success!' 143 }); 144 145 const getIssuers = () => callPublicFacet(GET_ISSUERS); 146 const getLength = ({ length }) => length; 147 await assert({ 148 unitLabel: 'publicFacet', 149 given: 'contract', 150 should: 'contain an instance', 151 actual: getLength(await getIssuers()), 152 expected: 4 153 }); 154 155 const fakeAtomKit = makeIssuerKit('not atom'); 156 const fakeUsdcKit = makeIssuerKit('not usdc'); 157 158 const aliceLoanTerms = { 159 collateralKey: 'ATOM', 160 collateralBrand: atomKit.brand, 161 debtKey: 'IST', 162 debtBrand: istKit.brand, 163 expiry: 1209600000n, 164 loanToValue: 70n, 165 interestRate: 5n 166 }; 167 168 const [debtBrandError, collateralBrandError] = await Promise.all([ 169 callPublicFacet(UNDERWRITE_LOAN, { 170 ...aliceLoanTerms, 171 debtBrand: fakeUsdcKit.brand 172 }), 173 callPublicFacet(UNDERWRITE_LOAN, { 174 ...aliceLoanTerms, 175 collateralBrand: fakeAtomKit.brand 176 }) 177 ]); 178 const fakeErrorObj = (brandName = 'test brand') => ({ 179 displayMessage: 'Error validating inputs', 180 error: new Error( 181 '[object Alleged: ' + brandName + ' brand] is not a recognized brand.' 182 ) 183 }); 184 185 await assert({ 186 given: 'an offer containing an invalid debt brand', 187 should: 'gracefully handle the error', 188 actual: debtBrandError, 189 expected: fakeErrorObj('not usdc') 190 }); 191 192 await assert({ 193 given: 'an offer containing an invalid collateral brand', 194 should: 'gracefully handle the error', 195 actual: collateralBrandError, 196 expected: fakeErrorObj('not atom') 197 }); 198 }); 199 200 describe('p2p loan', async (assert, done) => { 201 const KEYWORDS = { 202 IST: 'IST', 203 ATOM: 'ATOM' 204 }; 205 206 const { IST: istKeyword, ATOM: atomKeyword } = KEYWORDS; 207 208 const { contractInstance, zoe, fakeVatAdmin, utilsInstance } = 209 await setupContract(); 210 211 const zoeIssuer = await E(zoe).getInvitationIssuer(); 212 213 const callCreatorFacet = callFacet(contractInstance.creatorFacet); 214 const callPublicFacet = callFacet(contractInstance.publicFacet); 215 216 const { 217 public: { 218 GET_PUBLIC_TOPICS, 219 GET_ISSUERS, 220 GET_FUNDED_LOANS, 221 SAVE_ISSUERS, 222 UNDERWRITE_LOAN 223 } 224 } = facetMethods; 225 const { metrics } = await callPublicFacet(GET_PUBLIC_TOPICS); 226 227 await assert({ 228 unitLabel: 'publicFacet', 229 given: 'getPublicTopics', 230 should: 'return metrics object with 3 keys.', 231 actual: countKeys(metrics), 232 expected: 3 233 }); 234 235 await assert({ 236 unitLabel: 'publicFacet', 237 given: 'getPublicTopics', 238 should: 'return metrics object with the correct descriptin.', 239 actual: metrics.description, 240 expected: 'P2P Lending Marketplace Metrics' 241 }); 242 243 await assert({ 244 unitLabel: 'E(creatorFacet).saveIssuers()', 245 given: 'an issuer keyword record', 246 should: 'return a success message', 247 actual: await callCreatorFacet(SAVE_ISSUERS, { 248 ATOM: atomKit.issuer, 249 IST: istKit.issuer, 250 BLD: bldKit.issuer, 251 USDC: USDCKit.issuer 252 }), 253 expected: 'Add issuers success!' 254 }); 255 256 const getIssuers = () => callPublicFacet(GET_ISSUERS); 257 const getLength = ({ length }) => length; 258 await assert({ 259 unitLabel: 'publicFacet', 260 given: 'contract', 261 should: 'contain an instance', 262 actual: getLength(await getIssuers()), 263 expected: 4 264 }); 265 266 const aliceLoanTerms = { 267 collateralKey: 'ATOM', 268 collateralBrand: atomKit.brand, 269 debtKey: 'IST', 270 debtBrand: istKit.brand, 271 expiry: 1209600000n, 272 loanToValue: 70n, 273 interestRate: 5n 274 }; 275 276 const aliceLoanInvitation = await callPublicFacet( 277 UNDERWRITE_LOAN, 278 aliceLoanTerms 279 ); 280 281 assert({ 282 unitLabel: 'publicFacet', 283 given: 'underwriteLoan', 284 should: 'prepare the loanAgreeentReducer state with the terms', 285 actual: await zoeIssuer.isLive(aliceLoanInvitation), 286 expected: true 287 }); 288 289 const aliceLoanDetails = await E(zoe).getInvitationDetails( 290 aliceLoanInvitation 291 ); 292 293 const firstLoanId = 'loan-0'; 294 295 await assert({ 296 unitLabel: 'aliceLoanInvitation', 297 given: 'getInvitationDetails', 298 should: 'reflect in terms specified upon the creation of the loan', 299 actual: aliceLoanDetails.customDetails, 300 expected: { ...aliceLoanTerms, id: firstLoanId } 301 }); 302 303 const sevenThousandIST = ist(7000n); 304 305 const createZoeOffer = proposal => payment => (invitation) => E(zoe).offer( 306 invitation, 307 proposal, 308 payment 309 ) 310 311 const offerOne = createZoeOffer({ give: { IST: sevenThousandIST } })(istKit.mint.mintPayment(sevenThousandIST)) 312 313 314 315 const aliceLoanSeat = await E(zoe).offer( 316 aliceLoanInvitation, 317 { 318 give: { IST: sevenThousandIST }, 319 want: {} 320 }, 321 { IST: istKit.mint.mintPayment(sevenThousandIST) } 322 ); 323 324 325 const aliceLoanExo = await E(aliceLoanSeat).getOfferResult(); 326 327 const alicesSecondLoanInvitation = await callPublicFacet(UNDERWRITE_LOAN, { 328 ...aliceLoanTerms, 329 expiry: ONE_DAY 330 }); 331 const aliceSecondLoanDetails = await E(zoe).getInvitationDetails( 332 alicesSecondLoanInvitation 333 ); 334 335 336 await assert({ 337 unitLabel: 'aliceSecondLoanInvitation', 338 given: 'isLive check', 339 should: 'return true', 340 actual: await zoeIssuer.isLive(alicesSecondLoanInvitation), 341 expected: true 342 }) 343 344 345 await assert({ 346 unitLabel: 'aliceSecondLoanInvitation', 347 given: 'getInvitationDetails', 348 should: 'reflect in terms specified upon the creation of the loan', 349 actual: aliceSecondLoanDetails.customDetails, 350 expected: { ...aliceLoanTerms, id: 'loan-1', expiry: ONE_DAY } 351 }); 352 353 354 const getSeats = ({ debtSeat, lenderSeat }) => ({ 355 debtSeat, 356 lenderSeat 357 }); 358 359 360 const aliceSecondLoanSeat = await E(zoe).offer( 361 alicesSecondLoanInvitation, 362 { 363 give: { IST: ist(25_000n) }, 364 want: {} 365 }, 366 { IST: istKit.mint.mintPayment(ist(25_000n)) } 367 ); 368 369 370 await E(aliceSecondLoanSeat).getOfferResult(); 371 372 const [fundedLoan, secondLoan] = await callPublicFacet(GET_FUNDED_LOANS).then(res => { 373 console.log({ res }); 374 return [...res.entries()].map(([key, value]) => { 375 return { key, value }; 376 }); 377 }); 378 379 await assert({ 380 unitLabel: 'fundedLoans Map', 381 given: '.entries()', 382 should: 383 'return an array containing a fundedLoan exo object which corresponds to alices newly funded loan.', 384 actual: fundedLoan.key, 385 expected: firstLoanId 386 }); 387 388 389 const secondLoanBalances = secondLoan.value.public.getSeatBalances(); 390 await assert({ 391 unitLabel: 'aliceSecondLoanSeat', 392 given: 'getOfferResult', 393 should: 394 'return a loan object.', 395 actual: secondLoanBalances.debtSeat['IST'], 396 expected: ist(25_000n) 397 }); 398 399 await assert({ 400 unitLabel: 'fundedLoans Map', 401 given: 'two funded loans', 402 should: 403 'hold references to both loan.', 404 actual: secondLoan.key, 405 expected: 'loan-1' 406 }); 407 const loan0Seats = await fundedLoan.value.public.getSeatBalances(); 408 409 const { debtSeat, lenderSeat } = await getSeats(loan0Seats); 410 411 await assert({ 412 unitLabel: 'fundedLoans Map', 413 given: 'a fundedLoan', 414 should: 415 'expose a method to check the total assets allocated to that particular loan.', 416 actual: debtSeat[istKeyword], 417 expected: sevenThousandIST 418 }); 419 420 const borrowInvitation = fundedLoan.value.invitationMakers.makeFulfillLoan(); 421 422 await assert({ 423 unitLabel: 'fundedLoans Map', 424 given: 'a fundedLoan', 425 should: 426 'expose a method that returns an invitation for entering into a loan.', 427 actual: await zoeIssuer.isLive(borrowInvitation), 428 expected: true 429 }); 430 431 const bobSeat = await E(zoe).offer( 432 borrowInvitation, 433 { 434 give: { ATOM: atoms(1000n) }, 435 want: { IST: AmountMath.make(istKit.brand, 7000n) } 436 }, 437 { ATOM: atomKit.mint.mintPayment(atoms(1000n)) } 438 ); 439 440 const borrowOfferResult = await E(bobSeat).getPayout('IST'); 441 442 await assert({ 443 unitLabel: 'BorrowBobSeat', 444 given: 'a live borrowInvitation', 445 should: 446 'successfully exchange his collateral in exchange for the request IST.', 447 actual: await istKit.issuer.getAmountOf(borrowOfferResult), 448 expected: sevenThousandIST 449 }); 450 451 const { loan: loanTopic } = fundedLoan.value.holder.getPublicTopics(); 452 453 const makeStoragePath = rootPath => childPath => `${rootPath}.${childPath}`; 454 455 const loansStoragePath = makeStoragePath('p2pMetrics.loans'); 456 457 assert({ 458 unitLabel: 'fundedLoan Exo', 459 given: 'holder.getPublicTopics()', 460 should: 'return a topic object with the correct storagePath.', 461 actual: await loanTopic.storagePath, 462 expected: loansStoragePath(firstLoanId) 463 }); 464 465 assert({ 466 unitLabel: 'fundedLoan Exo', 467 given: 'holder.getPublicTopics()', 468 should: 'return a topic object with the correct description.', 469 actual: loanTopic.description, 470 expected: 'Loan Holder status' 471 }); 472 473 console.log({ loanTopic }); 474 475 // [TG] todo add tests for issuer kit notifications 476 // assert({ 477 // unitLabel: 'fundedLoan Exo', 478 // given: 'holder.getPublicTopics()', 479 // should: 'return a topic object with a subscriber object..', 480 // actual: loanTopic.subscriber, 481 // expected: 'Loan Holder status' 482 // }); 483 484 await done; 485 }); 486 487 describe('p2p loan - expiration handler - lender claims collateral', async (assert, done) => { 488 const KEYWORDS = { 489 IST: 'IST', 490 ATOM: 'ATOM' 491 }; 492 493 const LOAN_EXPIRY = 5n; 494 495 const { IST: istKeyword, ATOM: atomKeyword } = KEYWORDS; 496 497 const { contractInstance, zoe, timerService } = await setupContract( 498 contractPath 499 ); 500 501 const zoeIssuer = await E(zoe).getInvitationIssuer(); 502 503 const callCreatorFacet = callFacet(contractInstance.creatorFacet); 504 const callPublicFacet = callFacet(contractInstance.publicFacet); 505 506 const { 507 public: { 508 GET_PUBLIC_TOPICS, 509 GET_ISSUERS, 510 GET_FUNDED_LOANS, 511 SAVE_ISSUERS, 512 UNDERWRITE_LOAN 513 } 514 } = facetMethods; 515 const { metrics } = await callPublicFacet(GET_PUBLIC_TOPICS); 516 517 await assert({ 518 unitLabel: 'publicFacet', 519 given: 'getPublicTopics', 520 should: 'return metrics object with 3 keys.', 521 actual: countKeys(metrics), 522 expected: 3 523 }); 524 525 await assert({ 526 unitLabel: 'publicFacet', 527 given: 'getPublicTopics', 528 should: 'return metrics object with the correct descriptin.', 529 actual: metrics.description, 530 expected: 'P2P Lending Marketplace Metrics' 531 }); 532 533 await assert({ 534 unitLabel: 'E(creatorFacet).saveIssuers()', 535 given: 'an issuer keyword record', 536 should: 'return a success message', 537 actual: await callCreatorFacet(SAVE_ISSUERS, { 538 ATOM: atomKit.issuer, 539 IST: istKit.issuer, 540 BLD: bldKit.issuer, 541 USDC: USDCKit.issuer 542 }), 543 expected: 'Add issuers success!' 544 }); 545 546 const getIssuers = () => callPublicFacet(GET_ISSUERS); 547 const getLength = ({ length }) => length; 548 await assert({ 549 unitLabel: 'publicFacet', 550 given: 'contract', 551 should: 'contain an instance', 552 actual: getLength(await getIssuers()), 553 expected: 4 554 }); 555 556 const aliceLoanTerms = { 557 collateralKey: 'ATOM', 558 collateralBrand: atomKit.brand, 559 debtKey: 'IST', 560 debtBrand: istKit.brand, 561 expiry: LOAN_EXPIRY, 562 loanToValue: 70n, 563 interestRate: 5n 564 }; 565 566 const aliceLoanInvitation = await callPublicFacet( 567 UNDERWRITE_LOAN, 568 aliceLoanTerms 569 ); 570 571 assert({ 572 unitLabel: 'publicFacet', 573 given: 'underwriteLoan', 574 should: 'prepare the loanAgreeentReducer state with the terms', 575 actual: await zoeIssuer.isLive(aliceLoanInvitation), 576 expected: true 577 }); 578 579 const aliceLoanDetails = await E(zoe).getInvitationDetails( 580 aliceLoanInvitation 581 ); 582 583 const firstLoanId = 'loan-0'; 584 585 await assert({ 586 unitLabel: 'aliceLoanInvitation', 587 given: 'getInvitationDetails', 588 should: 'reflect in terms specified upon the creation of the loan', 589 actual: aliceLoanDetails.customDetails, 590 expected: { ...aliceLoanTerms, id: firstLoanId } 591 }); 592 593 const sevenThousandIST = ist(7000n); 594 const aliceLoanSeat = await E(zoe).offer( 595 aliceLoanInvitation, 596 { 597 give: { IST: sevenThousandIST }, 598 want: {} 599 }, 600 { IST: istKit.mint.mintPayment(sevenThousandIST) } 601 ); 602 603 await E(aliceLoanSeat).getOfferResult(); 604 605 const alicesSecondLoanInvitation = await callPublicFacet(UNDERWRITE_LOAN, { 606 ...aliceLoanTerms, 607 expiry: ONE_DAY 608 }); 609 const aliceSecondLoanDetails = await E(zoe).getInvitationDetails( 610 alicesSecondLoanInvitation 611 ); 612 613 await assert({ 614 unitLabel: 'aliceSecondLoanInvitation', 615 given: 'getInvitationDetails', 616 should: 'reflect in terms specified upon the creation of the loan', 617 actual: aliceSecondLoanDetails.customDetails, 618 expected: { ...aliceLoanTerms, id: 'loan-1', expiry: ONE_DAY } 619 }); 620 621 const [fundedLoan] = await callPublicFacet(GET_FUNDED_LOANS).then(res => { 622 return [...res.entries()].map(([key, value]) => { 623 return { key, value }; 624 }); 625 }); 626 const borrowInvitation = fundedLoan.value.invitationMakers.makeFulfillLoan(); 627 628 await assert({ 629 unitLabel: 'fundedLoans Map', 630 given: 'a fundedLoan', 631 should: 632 'expose a method that returns an invitation for entering into a loan.', 633 actual: await zoeIssuer.isLive(borrowInvitation), 634 expected: true 635 }); 636 637 const bobSeat = await E(zoe).offer( 638 borrowInvitation, 639 { 640 give: { ATOM: atoms(1000n) }, 641 want: { IST: AmountMath.make(istKit.brand, 7000n) } 642 }, 643 { ATOM: atomKit.mint.mintPayment(atoms(1000n)) } 644 ); 645 646 const borrowOfferResult = await E(bobSeat).getPayout('IST'); 647 648 await assert({ 649 unitLabel: 'BorrowBobSeat', 650 given: 'a live borrowInvitation', 651 should: 652 'successfully exchange his collateral in exchange for the request IST.', 653 actual: await istKit.issuer.getAmountOf(borrowOfferResult), 654 expected: sevenThousandIST 655 }); 656 657 // fast forward, to when the loan has expired 658 timerService.tickN(LOAN_EXPIRY); 659 await assert({ 660 unitLabel: 'aliceLoanSeeat', 661 given: 'an expired loan', 662 should: 'receive Collateral from the Collateral seat', 663 actual: await atomKit.issuer.getAmountOf( 664 ( 665 await E(aliceLoanSeat).getPayouts() 666 ).ATOM 667 ), 668 expected: atoms(1000n) 669 }); 670 671 await assert({ 672 given: 'E(bobSeat).hasExited()', 673 should: 'return true following loan expiration.', 674 actual: await E(bobSeat).hasExited(), 675 expected: true 676 }); 677 678 await assert({ 679 given: 'E(aliceLoanSeat).hasExited()', 680 should: 'return true following loan expiration.', 681 actual: await E(aliceLoanSeat).hasExited(), 682 expected: true 683 }); 684 685 const bobAtomPayout = await E(bobSeat).getPayout('ATOM'); 686 687 await assert({ 688 unitLabel: 'bobLoanSeat', 689 given: 'an expired loan', 690 should: 'be unable to claim his initial collateral.', 691 actual: await atomKit.issuer.getAmountOf(bobAtomPayout), 692 expected: atoms(0n) 693 }); 694 695 await done; 696 });