From a99f2c778f4b11a80cc4241c86583b3ae404fe9d Mon Sep 17 00:00:00 2001 From: Chris Hibbert Date: Fri, 9 Oct 2020 09:37:50 -0700 Subject: [PATCH] feat: a call spread option contract and tests. Implementation of a fully collateralized call spread option, following Joe Clark's description. This is a combination of a bought call option and a sold call option at a higher strike price. The contracts are sold in pairs, and the buyers of the two positions together invest the entire amount that will be paid out. This option is settled financially. Neither party is expected to have ownership of the underlying asset at the start, and neither expects to take delivery at closing. zoe.startInstance() takes an issuerKeywordRecord that specifies the issuers for the keywords Underlying, Strike, and Collateral. The payout uses Collateral. The price oracle quotes the value of the Underlying in the same units as the Strike prices. creatorFacet has a method makeInvitationPair(), that takes terms that specifies { expiration, underlyingAmount, priceAuthority, strikePrice1, strikePrice2, settlementAmount, buyPercent }. ownerFacet.makeInvitationPair() returns two invitations, which can be sold separately. They settle when the priceAuthority announces the settlement amout as of it's pre-programmed closing time. closes: #1829 --- packages/zoe/src/contracts/callSpread.js | 219 ++++++ .../unitTests/contracts/test-callSpread.js | 664 ++++++++++++++++++ packages/zoe/test/zoeTestHelpers.js | 9 +- 3 files changed, 888 insertions(+), 4 deletions(-) create mode 100644 packages/zoe/src/contracts/callSpread.js create mode 100644 packages/zoe/test/unitTests/contracts/test-callSpread.js diff --git a/packages/zoe/src/contracts/callSpread.js b/packages/zoe/src/contracts/callSpread.js new file mode 100644 index 000000000000..7a483508d30a --- /dev/null +++ b/packages/zoe/src/contracts/callSpread.js @@ -0,0 +1,219 @@ +// @ts-check +import '../../exported'; + +import { assert, details } from '@agoric/assert'; + +// Eventually will be importable from '@agoric/zoe-contract-support' +import { makePromiseKit } from '@agoric/promise-kit'; +import { E } from '@agoric/eventual-send'; +import { + assertProposalShape, + depositToSeat, + natSafeMath, + trade, + assertUsesNatMath, +} from '../contractSupport'; + +const { subtract, multiply, floorDivide } = natSafeMath; + +/** + * Constants for long and short positions. + * + * @type {{ LONG: 'long', SHORT: 'short' }} + */ +const Position = { + LONG: 'long', + SHORT: 'short', +}; + +const PERCENT_BASE = 100; +const inverse = percent => subtract(PERCENT_BASE, percent); + +/** + * This contract implements a fully collateralized call spread. This is a + * combination of a call option bought at one strike price and a second call + * option sold at a higher price. The contracts are sold in pairs, and the + * purchaser pays the entire amount that will be paid out. The individual + * options are ERTP invitations that are suitable for resale. + * + * This option is settled financially. There is no requirement that the original + * purchaser have ownership of the underlying asset at the start, and the + * beneficiaries shouldn't expect to take delivery at closing. + * + * The issuerKeywordRecord specifies the issuers for three keywords: Underlying, + * Strike, and Collateral. The payout is in Collateral. Strike amounts are used + * for the price oracle's quotes as to the value of the Underlying, as well as + * the strike prices in the terms. The terms include { expiration, + * underlyingAmount, priceAuthority, strikePrice1, strikePrice2, + * settlementAmount }. expiration is a time recognized by the priceAuthority. + * underlyingAmount is passed to the priceAuthority, so it could be an NFT or a + * fungible amount. strikePrice2 must be greater than strikePrice1. + * settlementAmount uses Collateral. + * + * creatorInvitation has terms that include the amounts of the two options as + * longOption and shortOption. When the creatorInvitation is exercised, the + * payout includes the two option positions, which are themselves invitations + * which can be exercised for free, and are valuable for their payouts. + * + * Future enhancements: + * + issue multiple option pairs with the same expiration from a single instance + * + create separate invitations to purchase the pieces of the option pair. + * (This would remove the current requirement that an intermediary have the + * total collateral available before the option descriptions have been + * created.) + * + exit the contract when both seats have been paid. + * + * @type {ContractStartFn} + */ +const start = zcf => { + // terms: underlyingAmount, priceAuthority, strike1, strike2, + // settlementAmount, expiration + + const terms = zcf.getTerms(); + const { + maths: { Collateral: collateralMath, Strike: strikeMath }, + } = terms; + assertUsesNatMath(zcf, collateralMath.getBrand()); + assertUsesNatMath(zcf, strikeMath.getBrand()); + // notice that we don't assert that the Underlying is fungible. + + assert( + strikeMath.isGTE(terms.strikePrice2, terms.strikePrice1), + details`strikePrice2 must be greater than strikePrice1`, + ); + + const { zcfSeat: collateralSeat } = zcf.makeEmptySeatKit(); + + // Since the seats for the payout of the settlement aren't created until the + // invitations for the options themselves are exercised, we don't have those + // seats at the time of creation of the options, so we use Promises, and + // resolve the payments when those promises resolve. + const seatPromiseKits = {}; + + seatPromiseKits[Position.LONG] = makePromiseKit(); + seatPromiseKits[Position.SHORT] = makePromiseKit(); + + function reallocateToSeat(position, sharePercent) { + seatPromiseKits[position].promise.then(seat => { + const currentCollateral = collateralSeat.getCurrentAllocation() + .Collateral; + const collateral = terms.settlementAmount; + const collateralShare = floorDivide( + multiply(collateral.value, sharePercent), + PERCENT_BASE, + ); + const seatPortion = collateralMath.make(collateralShare); + const collateralRemainder = collateralMath.subtract( + currentCollateral, + seatPortion, + ); + zcf.reallocate( + seat.stage({ Collateral: seatPortion }), + collateralSeat.stage({ Collateral: collateralRemainder }), + ); + seat.exit(); + }); + } + + function calculateLongShare(price) { + // longShare is the value of the underlying at close of the strikePrice + // percentage (base:100) computed from strikePrice + // scale that will be used to calculate the portion of collateral + // allocated to each party. + + if (strikeMath.isGTE(terms.strikePrice1, price)) { + return 0; + } else if (strikeMath.isGTE(price, terms.strikePrice2)) { + return 100; + } + + const denominator = strikeMath.subtract( + terms.strikePrice2, + terms.strikePrice1, + ).value; + const numerator = strikeMath.subtract(price, terms.strikePrice1).value; + return floorDivide(multiply(PERCENT_BASE, numerator), denominator); + } + + function payoffOptions(price) { + // either offer might be exercised late, so we pay the two seats + // separately. + const longShare = calculateLongShare(price); + reallocateToSeat(Position.LONG, longShare, terms); + reallocateToSeat(Position.SHORT, inverse(longShare), terms); + } + + function schedulePayoffs() { + terms.priceAuthority + .priceAtTime(terms.expiration, terms.underlyingAmount) + .then(price => payoffOptions(price)); + } + + function makeOptionInvitation(dir) { + const optionsTerms = harden({ + ...terms, + position: dir, + }); + return zcf.makeInvitation( + seat => seatPromiseKits[dir].resolve(seat), + `collect ${dir} payout`, + optionsTerms, + ); + } + + async function makeOptionPair() { + return { + longInvitation: makeOptionInvitation(Position.LONG), + shortInvitation: makeOptionInvitation(Position.SHORT), + }; + } + + async function makeInvitationToBuy() { + const { longInvitation, shortInvitation } = await makeOptionPair(); + const invitationIssuer = zcf.getInvitationIssuer(); + const longAmount = await E(invitationIssuer).getAmountOf(longInvitation); + const shortAmount = await E(invitationIssuer).getAmountOf(shortInvitation); + depositToSeat( + zcf, + collateralSeat, + { LongOption: longAmount, ShortOption: shortAmount }, + { LongOption: longInvitation, ShortOption: shortInvitation }, + ); + + // transfer collateral from longSeat to collateralSeat, then return a pair + // of callSpread invitations + /** @type {OfferHandler} */ + const pairBuyerPosition = longSeat => { + assertProposalShape(longSeat, { + give: { Collateral: null }, + want: { LongOption: null, ShortOption: null }, + }); + + trade( + zcf, + { + seat: collateralSeat, + gains: { Collateral: terms.settlementAmount }, + }, + { + seat: longSeat, + gains: { LongOption: longAmount, ShortOption: shortAmount }, + }, + ); + schedulePayoffs(); + longSeat.exit(); + }; + + const longTerms = harden({ + ...terms, + LongOption: longAmount, + ShortOption: shortAmount, + }); + return zcf.makeInvitation(pairBuyerPosition, `call spread pair`, longTerms); + } + + return harden({ creatorInvitation: makeInvitationToBuy() }); +}; + +harden(start); +export { start }; diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread.js b/packages/zoe/test/unitTests/contracts/test-callSpread.js new file mode 100644 index 000000000000..e12b64ac70b1 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/test-callSpread.js @@ -0,0 +1,664 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import '@agoric/install-ses'; +// eslint-disable-next-line import/no-extraneous-dependencies +import test from 'ava'; +import { E } from '@agoric/eventual-send'; +import '../../../exported'; +import { makePromiseKit } from '@agoric/promise-kit'; +import buildManualTimer from '../../../tools/manualTimer'; + +import { setup } from '../setupBasicMints'; +import { installationPFromSource } from '../installFromSource'; +import { assertPayoutDeposit, assertPayoutAmount } from '../../zoeTestHelpers'; + +const callSpread = `${__dirname}/../../../src/contracts/callSpread`; +const simpleExchange = `${__dirname}/../../../src/contracts/simpleExchange`; + +function makeFakePriceAuthority( + timer, + underlyingAmountMath, + strikeAmountMath, + priceSchedule, +) { + function priceFromSchedule(strikeTime) { + let freshestPrice = 0; + let freshestTime = -1; + for (const tick of priceSchedule) { + if (tick.time > freshestTime && tick.time <= strikeTime) { + freshestTime = tick.time; + freshestPrice = tick.price; + } + } + return freshestPrice; + } + + const priceAuthority = { + getCurrentPrice: underlyingAmount => { + const underlyingValue = underlyingAmountMath.getValue(underlyingAmount); + return E(timer) + .getCurrentTimestamp() + .then(now => { + const price = priceFromSchedule(now); + return strikeAmountMath.make(price * underlyingValue); + }); + }, + priceAtTime: (timeStamp, underlyingAmount) => { + const { promise, resolve } = makePromiseKit(); + + underlyingAmountMath.getValue(underlyingAmount); + E(timer).setWakeup( + timeStamp, + harden({ + wake: () => { + return resolve(priceAuthority.getCurrentPrice(underlyingAmount)); + }, + }), + ); + return promise; + }, + }; + return priceAuthority; +} + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. The price oracle takes an amount in Underlying, and +// gives the value in Moola. +test('callSpread below Strike1', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature at a low price, and carol will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + LongOption: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 1, price: 35 }, + { time: 2, price: 15 }, + { time: 3, price: 28 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit(t, bobPayout, bobBucksPurse, bucks(0)); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(300), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([bobDeposit, carolDeposit]); +}); + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. +test('callSpread above Strike2', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature at a high price, and bob will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + LongOption: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 55 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(300), + ); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(0), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([bobDeposit, carolDeposit]); +}); + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. +test('callSpread, mid-strike', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature, and both will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + LongOption: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 45 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(225), + ); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(75), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([bobDeposit, carolDeposit]); +}); + +// Underlying is in Simoleans. Collateral, strikePrice and Payout are in bucks. +// Value is in Moola. Carol waits to collect until after settlement +test('callSpread, late exercise', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and give the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature, and both will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + LongOption: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 45 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: bobLongOption, + ShortOption: carolShortOption, + } = await aliceSeat.getPayouts(); + + const bobOptionSeat = await zoe.offer(bobLongOption); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(225), + ); + + manualTimer.tick(); + manualTimer.tick(); + + const carolOptionSeat = await zoe.offer(carolShortOption); + const carolPayout = await carolOptionSeat.getPayout('Collateral'); + const carolDepositAmount = await E(carolBucksPurse).deposit(carolPayout); + await t.deepEqual( + carolDepositAmount, + bucks(75), + `payout was ${carolDepositAmount.value}, expected 75`, + ); + await Promise.all([bobDeposit]); +}); + +test('callSpread, sell options', async t => { + const { + moolaIssuer, + simoleanIssuer, + moola, + simoleans, + bucksIssuer, + bucksMint, + bucks, + zoe, + amountMaths, + } = setup(); + const installation = await installationPFromSource(zoe, callSpread); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Alice will create and fund a call spread contract, and sell the invitations + // to Bob and Carol. Bob and Carol will promptly schedule collection of funds. + // The spread will then mature, and both will get paid. + + // Setup Alice + const aliceBucksPayment = bucksMint.mintPayment(bucks(300)); + const aliceBucksPurse = bucksIssuer.makeEmptyPurse(); + // Setup Bob + const bobBucksPurse = bucksIssuer.makeEmptyPurse(); + const bobBucksPayment = bucksMint.mintPayment(bucks(200)); + // Setup Carol + const carolBucksPurse = bucksIssuer.makeEmptyPurse(); + const carolBucksPayment = bucksMint.mintPayment(bucks(100)); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + LongOption: invitationIssuer, + }); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + manualTimer, + amountMaths.get('simoleans'), + amountMaths.get('moola'), + [ + { time: 0, price: 20 }, + { time: 3, price: 45 }, + ], + ); + // underlying is 2 Simoleans, strike range is 30-50 (doubled) + const terms = harden({ + expiration: 3, + underlyingAmount: simoleans(2), + priceAuthority, + strikePrice1: moola(60), + strikePrice2: moola(100), + settlementAmount: bucks(300), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const optionAmount = await invitationIssuer.getAmountOf(creatorInvitation); + const longOptionAmount = optionAmount.value[0].LongOption; + const shortOptionAmount = optionAmount.value[0].ShortOption; + + const aliceProposal = harden({ + want: { LongOption: longOptionAmount, ShortOption: shortOptionAmount }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + const aliceOption = await aliceSeat.getOfferResult(); + t.truthy(invitationIssuer.isLive(aliceOption)); + const { + LongOption: longOption, + ShortOption: shortOption, + } = await aliceSeat.getPayouts(); + + const exchangeInstallation = await installationPFromSource( + zoe, + simpleExchange, + ); + const { publicFacet: exchangePublic } = await zoe.startInstance( + exchangeInstallation, + { + Asset: invitationIssuer, + Price: bucksIssuer, + }, + ); + + // Alice offers to sell the long invitation + const aliceLongInvitation = E(exchangePublic).makeInvitation(); + const proposalLong = harden({ + give: { Asset: longOptionAmount }, + want: { Price: bucks(200) }, + }); + const aliceSellLongSeat = await zoe.offer(aliceLongInvitation, proposalLong, { + Asset: longOption, + }); + const aliceLong = assertPayoutDeposit( + t, + aliceSellLongSeat.getPayout('Price'), + aliceBucksPurse, + bucks(200), + ); + + // Alice offers to sell the short invitation + const aliceShortInvitation = E(exchangePublic).makeInvitation(); + const proposalShort = harden({ + give: { Asset: shortOptionAmount }, + want: { Price: bucks(100) }, + }); + const aliceSellShortSeat = await zoe.offer( + aliceShortInvitation, + proposalShort, + { Asset: shortOption }, + ); + const aliceShort = assertPayoutDeposit( + t, + aliceSellShortSeat.getPayout('Price'), + carolBucksPurse, + bucks(100), + ); + + // Bob buys the long invitation + const bobLongInvitation = E(exchangePublic).makeInvitation(); + const bobProposal = harden({ + give: { Price: bucks(200) }, + want: { Asset: longOptionAmount }, + }); + const bobBuySeat = await zoe.offer(bobLongInvitation, bobProposal, { + Price: bobBucksPayment, + }); + const longInvitationPayout = await bobBuySeat.getPayout('Asset'); + assertPayoutAmount( + t, + invitationIssuer, + longInvitationPayout, + longOptionAmount, + ); + const bobOptionSeat = await zoe.offer(longInvitationPayout); + const bobPayout = bobOptionSeat.getPayout('Collateral'); + const bobDeposit = assertPayoutDeposit( + t, + bobPayout, + bobBucksPurse, + bucks(225), + ); + + // Bob buys the Short invitation + const carolShortInvitation = E(exchangePublic).makeInvitation(); + const carolProposal = harden({ + give: { Price: bucks(100) }, + want: { Asset: shortOptionAmount }, + }); + const carolBuySeat = await zoe.offer(carolShortInvitation, carolProposal, { + Price: carolBucksPayment, + }); + const ShortInvitationPayout = await carolBuySeat.getPayout('Asset'); + assertPayoutAmount( + t, + invitationIssuer, + ShortInvitationPayout, + shortOptionAmount, + ); + const carolOptionSeat = await zoe.offer(ShortInvitationPayout); + const carolPayout = carolOptionSeat.getPayout('Collateral'); + const carolDeposit = assertPayoutDeposit( + t, + carolPayout, + carolBucksPurse, + bucks(75), + ); + + manualTimer.tick(); + manualTimer.tick(); + await Promise.all([aliceLong, aliceShort, bobDeposit, carolDeposit]); +}); diff --git a/packages/zoe/test/zoeTestHelpers.js b/packages/zoe/test/zoeTestHelpers.js index cc962016104a..f020a02bd7a8 100644 --- a/packages/zoe/test/zoeTestHelpers.js +++ b/packages/zoe/test/zoeTestHelpers.js @@ -13,17 +13,18 @@ export const assertPayoutAmount = async ( t.deepEqual(amount, expectedAmount, `${label} payout was ${amount.value}`); }; +// Returns a promise that can be awaited in tests to ensure the check completes. export const assertPayoutDeposit = (t, payout, purse, amount) => { - payout.then(payment => { + return payout.then(payment => { E(purse) .deposit(payment) - .then(payoutAmount => { + .then(payoutAmount => t.deepEqual( payoutAmount, amount, `payout was ${payoutAmount.value}, expected ${amount}.value`, - ); - }); + ), + ); }); };