diff --git a/packages/treasury/src/runLoC.js b/packages/treasury/src/runLoC.js index 921cc69ad515..0651d25f618e 100644 --- a/packages/treasury/src/runLoC.js +++ b/packages/treasury/src/runLoC.js @@ -1,24 +1,43 @@ // @ts-check +import { AmountMath } from '@agoric/ertp'; import { Far } from '@agoric/marshal'; -import { assertProposalShape } from '@agoric/zoe/src/contractSupport'; +import { + assertIsRatio, + assertProposalShape, + ceilMultiplyBy, + floorMultiplyBy, +} from '@agoric/zoe/src/contractSupport/index.js'; + +const { details: X, quote: q } = assert; /** * @param { ContractFacet } zcf * @param {{ feeMintAccess: FeeMintAccess }} privateArgs */ const start = async (zcf, privateArgs) => { - const { governedParams } = zcf.getTerms(); + const { + governedParams, + issuers, + collateralPrice, + collateralizationRate, + } = zcf.getTerms(); + assertIsRatio(collateralPrice); + assertIsRatio(collateralizationRate); const { feeMintAccess } = privateArgs; const runMint = await zcf.registerFeeMint('RUN', feeMintAccess); + const { brand: runBrand, issuer: runIssuer } = runMint.getIssuerRecord(); const revealRunBrandToTest = () => { - const { brand: runBrand, issuer: runIssuer } = runMint.getIssuerRecord(); - return harden({ runMint, runBrand, runIssuer }); }; zcf.setTestJig(revealRunBrandToTest); + assert( + collateralPrice.numerator.brand === runBrand, + X`${collateralPrice} not in RUN`, + ); + /** @type { OfferHandler } */ const handleOffer = (seat, _offerArgs = undefined) => { assertProposalShape(seat, { @@ -30,10 +49,20 @@ const start = async (zcf, privateArgs) => { want: { RUN: runWanted }, } = seat.getProposal(); - console.log('@@TODO: check attestation', a); + assert(Array.isArray(a.value)); + // TODO: check that we need to check address here + const [{ address, amountLiened }] = a.value; + const maxAvailable = floorMultiplyBy(amountLiened, collateralPrice); + const collateralizedRun = ceilMultiplyBy(runWanted, collateralizationRate); + assert( + AmountMath.isGTE(maxAvailable, collateralizedRun), + X`${amountLiened} at price ${collateralPrice} not enough to borrow ${runWanted} with ${collateralizationRate}`, + ); runMint.mintGains(harden({ RUN: runWanted }), seat); seat.exit(); - return '@@TODO: this transaction succeeded, but perhaps it should not have.'; + return `borrowed ${q(runWanted)} against ${q(amountLiened)} at price ${q( + collateralPrice, + )} and rate ${q(collateralizationRate)}`; }; const publicFacet = Far('Line of Credit API', { diff --git a/packages/treasury/test/test-runLoC.js b/packages/treasury/test/test-runLoC.js index 9aa399e5d51f..3db6d22d342a 100644 --- a/packages/treasury/test/test-runLoC.js +++ b/packages/treasury/test/test-runLoC.js @@ -8,6 +8,7 @@ import { Far, makeLoopback } from '@agoric/captp'; import { resolve as metaResolve } from 'import-meta-resolve'; import bundleSource from '@agoric/bundle-source'; import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; +import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; // import { makeLoopback } from '@agoric/captp'; /** @@ -20,9 +21,28 @@ import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; const { assign, entries, fromEntries, keys, values } = Object; const { details: X } = assert; +/** + * @param {Record} obj + * @param {(v: V) => U} f + * @returns {Record} + * @template V + * @template U + */ const mapValues = (obj, f) => fromEntries(entries(obj).map(([p, v]) => [p, f(v)])); +/** + * @param {X[]} xs + * @param {Y[]} ys + * @returns {[X, Y][]} + * @template X + * @template Y + */ const zip = (xs, ys) => xs.map((x, i) => [x, ys[i]]); +/** + * @param {Record>} obj + * @returns {Promise>} + * @template V + */ const allValues = async obj => fromEntries(zip(keys(obj), await Promise.all(values(obj)))); @@ -60,10 +80,12 @@ const genesis = async () => { feeMintAccess: nonFarFeeMintAccess, } = makeZoeKit(makeFakeVatAdmin(setJig, makeRemote).admin); const feePurse = E(nonFarZoeService).makeFeePurse(); + const { brand: runBrandThere } = await E(feePurse).getCurrentAmount(); + const runBrand = await makeFar(runBrandThere); const zoeService = await E(nonFarZoeService).bindDefaultFeePurse(feePurse); const zoe = makeFar(zoeService); const feeMintAccess = await makeFar(nonFarFeeMintAccess); - return { zoe, feeMintAccess, getJig: () => testJig }; + return { zoe, feeMintAccess, runBrand, getJig: () => testJig }; }; test('RUN mint access', async t => { @@ -91,27 +113,19 @@ const startAttestation = async (t, zoe) => { harden({ Underlying: bldIssuerKit.issuer }), harden({ expiringAttName: 'BldAttGov', - returnableAttName: 'BldAttLoc', + returnableAttName: 'BldAttLoC', }), ); return { bldIssuerKit, publicFacet, creatorFacet }; }; -const chainState = harden({ - currentTime: 10n, - accounts: { - address1: { - total: 500n, - bonded: 200n, - locked: 10n, - }, - }, -}); - /** * @param {Brand} uBrand + * @param { string } myAddress + * @param {Account} account + * @typedef {{ total: bigint, bonded: bigint, locked: bigint}} Account */ -const makeStakeReporter = uBrand => { +const makeStakeReporter = (uBrand, myAddress, account) => { const ubld = v => AmountMath.make(uBrand, v); return Far('stakeReporter', { /** @@ -120,11 +134,10 @@ const makeStakeReporter = uBrand => { */ getAccountState: (address, brand) => { assert(brand === uBrand, X`unexpected brand: ${brand}`); - const account = chainState.accounts[address]; - assert(account, X`no such account: ${address}`); + assert(address === myAddress, X`no such account: ${address}`); return harden({ ...mapValues(account, ubld), - currentTime: chainState.currentTime, + currentTime: 60n, }); }, }); @@ -138,67 +151,150 @@ test('start attestation', async t => { const { brand: bldBrand } = a.bldIssuerKit; const ubld = v => AmountMath.make(bldBrand, v); - a.creatorFacet.addAuthority(makeStakeReporter(bldBrand)); - - const attMaker = await E(a.creatorFacet).getAttMaker( - keys(chainState.accounts)[0], + a.creatorFacet.addAuthority( + makeStakeReporter(bldBrand, 'address1', { + total: 10n, + bonded: 9n, + locked: 1n, + }), ); - const expiration = chainState.currentTime + 5n; - const att = await E(attMaker).makeAttestations(ubld(10n), expiration); + + const attMaker = await E(a.creatorFacet).getAttMaker('address1'); + const expiration = 65n; + const att = await E(attMaker).makeAttestations(ubld(5n), expiration); t.log('attestation', att); const pmt = await att.returnable; t.log({ pmt }); t.pass(); }); -test('take out RUN line of credit', async t => { - assert.typeof(t.context, 'object'); - assert(t.context); +/** + * @param { string } title + * @param { Object } detail + * @param { Account } detail.account + * @param { bigint } detail.collateral + * @param { bigint } detail.runWanted + * @param { [bigint, bigint] } detail.price + * @param { [bigint, bigint] } detail.rate + * @param { boolean } [detail.failAttestation] + * @param { boolean } [detail.failOffer] + */ +const testLoc = ( + title, + { account, collateral, runWanted, price, rate, failAttestation, failOffer }, +) => { + test(title, async t => { + assert.typeof(t.context, 'object'); + assert(t.context); - const { zoe, feeMintAccess, getJig } = await genesis(); - t.true(!!zoe); - t.true(!!feeMintAccess); - const a = await startAttestation(t, zoe); - const { brand: bldBrand } = a.bldIssuerKit; - a.creatorFacet.addAuthority(makeStakeReporter(bldBrand)); + const { zoe, feeMintAccess, runBrand, getJig } = await genesis(); + t.true(!!zoe); + t.true(!!feeMintAccess); + const a = await startAttestation(t, zoe); + const { brand: bldBrand } = a.bldIssuerKit; + a.creatorFacet.addAuthority( + makeStakeReporter(bldBrand, 'address1', account), + ); + const { returnable: attIssuer } = await E(a.publicFacet).getIssuers(); - const installation = await E(zoe).install(t.context.bundles.runLoC); - /** @type {StartLineOfCredit} */ - const { publicFacet } = await E(zoe).startInstance( - installation, - undefined, - undefined, - harden({ feeMintAccess }), - ); - /** @type {{ runBrand: Brand, runIssuer: Issuer }} */ - const { runBrand } = getJig(); - /** @param { bigint } value */ - const run = value => AmountMath.make(runBrand, value); + /** @param { bigint } value */ + const run = value => AmountMath.make(runBrand, value); - const lineOfCreditInvitation = await E(publicFacet).getInvitation(); + const collateralPrice = makeRatio(price[0], runBrand, price[1], bldBrand); + const collateralizationRate = makeRatio(rate[0], runBrand, rate[1]); + const installation = await E(zoe).install(t.context.bundles.runLoC); + /** @type {StartLineOfCredit} */ + const { publicFacet } = await E(zoe).startInstance( + installation, + harden({ Attestation: attIssuer }), + harden({ collateralPrice, collateralizationRate }), + harden({ feeMintAccess }), + ); + /** @type {{ runBrand: Brand, runIssuer: Issuer }} */ + const { runIssuer } = getJig(); - /** @param { bigint } v */ - const ubld = v => AmountMath.make(bldBrand, v); + const lineOfCreditInvitation = await E(publicFacet).getInvitation(); - const { returnable: attIssuer } = await E(a.publicFacet).getIssuers(); - const addr = keys(chainState.accounts)[0]; - const attMaker = await E(a.creatorFacet).getAttMaker(addr); - t.log({ addr, attMaker }); - - const expiration = chainState.currentTime + 1n; - const attPmt = await E.get(E(attMaker).makeAttestations(ubld(5n), expiration)) - .returnable; - t.log({ attPmt }); - const attAmt = await E(attIssuer).getAmountOf(attPmt); - - const seat = await E(zoe).offer( - lineOfCreditInvitation, - harden({ give: { Attestation: attAmt }, want: { RUN: run(100n) } }), - harden({ Attestation: attPmt }), - ); - const result = await E(seat).getOfferResult(); - t.true(await E(seat).hasExited()); - const p = await allValues(await E(seat).getPayouts()); - t.log('payout', p); - t.is(result, '@@TODO'); + /** @param { bigint } v */ + const ubld = v => AmountMath.make(bldBrand, v); + + const addr = 'address1'; + const attMaker = await E(a.creatorFacet).getAttMaker(addr); + t.log({ addr, attMaker }); + + const expiration = 61n; + const tryAttestations = E(attMaker).makeAttestations( + ubld(collateral), + expiration, + ); + if (failAttestation) { + await t.throwsAsync(tryAttestations); + return; + } + const attPmt = await E.get(tryAttestations).returnable; + t.log({ attPmt }); + const attAmt = await E(attIssuer).getAmountOf(attPmt); + + t.log({ + give: { Attestation: attAmt }, + want: { RUN: run(runWanted) }, + collateralPrice, + collateralizationRate, + }); + + const seat = await E(zoe).offer( + lineOfCreditInvitation, + harden({ give: { Attestation: attAmt }, want: { RUN: run(runWanted) } }), + harden({ Attestation: attPmt }), + ); + const result = E(seat).getOfferResult(); + if (failOffer) { + await t.throwsAsync(result); + return; + } + const resultValue = await result; + t.log({ resultValue }); + t.regex(resultValue, /^borrowed /); + + t.true(await E(seat).hasExited()); + + const p = await allValues(await E(seat).getPayouts()); + t.log('payout', p); + t.deepEqual(await E(runIssuer).getAmountOf(p.RUN), run(runWanted)); + }); +}; + +testLoc('borrow 100 RUN against 6000 BLD at 1.25, 5x', { + runWanted: 100n, + collateral: 6000n, + account: { total: 10_000n, bonded: 9_000n, locked: 10n }, + price: [125n, 100n], + rate: [5n, 1n], +}); + +testLoc('borrow 151 RUN against 600 BLD at 1.25, 5x', { + runWanted: 151n, + collateral: 600n, + price: [125n, 100n], + account: { total: 10_000n, bonded: 9_000n, locked: 10n }, + rate: [5n, 1n], + failOffer: true, +}); + +testLoc('borrow 100 RUN against 600 BLD at 0.15, 5x', { + runWanted: 100n, + collateral: 600n, + price: [15n, 100n], + account: { total: 10_000n, bonded: 9_000n, locked: 10n }, + rate: [5n, 1n], + failOffer: true, +}); + +testLoc('borrow against 6000 BLD without enough staked', { + runWanted: 100n, + collateral: 6000n, + account: { total: 10_000n, bonded: 5_000n, locked: 10n }, + price: [125n, 100n], + rate: [5n, 1n], + failAttestation: true, });