diff --git a/packages/zoe/src/contracts/callSpread.js b/packages/zoe/src/contracts/callSpread.js new file mode 100644 index 00000000000..2836fabf476 --- /dev/null +++ b/packages/zoe/src/contracts/callSpread.js @@ -0,0 +1,227 @@ +// @ts-check +import '../../exported'; + +import { assert, details } from '@agoric/assert'; +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; + +/** + * 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 invitations are produced 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 four 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 { timer, underlyingAmount, + * expiration, priceAuthority, strikePrice1, strikePrice2, settlementAmount }. + * The timer must be recognized by the priceAuthority. expiration is a time + * recognized by the timer. 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. + * + * The creatorInvitation has customProperties that include the amounts of the + * two options as longAmount and shortAmount. When the creatorInvitation is + * exercised, the payout includes the two option positions, which are themselves + * invitations which can be exercised for free, and provide the option payouts + * with the keyword Collateral. + * + * 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.) + * + increase the precision of the calculations. (change PERCENT_BASE to 10000) + */ + +/** + * 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); + +/** + * calculate the portion (as a percentage) of the collateral that should be + * allocated to the long side. + * + * @param strikeMath AmountMath the math to use + * @param price Amount the value of the underlying asset at closing that + * determines the payouts to the parties + * @param strikePrice1 Amount the lower strike price + * @param strikePrice2 Amount the upper strike price + * + * if price <= strikePrice1, return 0 + * if price >= strikePrice2, return 100. + * Otherwise return a number between 1 and 99 reflecting the position of price + * in the range from strikePrice1 to strikePrice2. + */ +function calculateLongShare(strikeMath, price, strikePrice1, strikePrice2) { + if (strikeMath.isGTE(strikePrice1, price)) { + return 0; + } else if (strikeMath.isGTE(price, strikePrice2)) { + return PERCENT_BASE; + } + + const denominator = strikeMath.subtract(strikePrice2, strikePrice1).value; + const numerator = strikeMath.subtract(price, strikePrice1).value; + return floorDivide(multiply(PERCENT_BASE, numerator), denominator); +} + +/** + * @type {ContractStartFn} + */ +const start = zcf => { + // terms: underlyingAmount, priceAuthority, strikePrice1, strikePrice2, + // settlementAmount, expiration, timer + + const terms = zcf.getTerms(); + const { + maths: { Collateral: collateralMath, Strike: strikeMath, Quote: quoteMath }, + brands: { Strike: strikeBrand }, + } = 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`, + ); + + zcf.saveIssuer(zcf.getInvitationIssuer(), 'Options'); + + // Create the two options immediately and allocate them to this seat. + 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 + // allocate the payouts when those promises resolve. + const seatPromiseKits = {}; + + seatPromiseKits[Position.LONG] = makePromiseKit(); + seatPromiseKits[Position.SHORT] = makePromiseKit(); + let seatsExited = 0; + + function reallocateToSeat(position, sharePercent) { + seatPromiseKits[position].promise.then(seat => { + const totalCollateral = terms.settlementAmount; + const collateralShare = floorDivide( + multiply(totalCollateral.value, sharePercent), + PERCENT_BASE, + ); + const seatPortion = collateralMath.make(collateralShare); + trade( + zcf, + { seat, gains: { Collateral: seatPortion } }, + { seat: collateralSeat, gains: {} }, + ); + seat.exit(); + seatsExited += 1; + const remainder = collateralSeat.getAmountAllocated('Collateral'); + if (collateralMath.isEmpty(remainder) && seatsExited === 2) { + zcf.shutdown('contract has been settled'); + } + }); + } + + function payoffOptions(priceQuoteAmount) { + const { price } = quoteMath.getValue(priceQuoteAmount)[0]; + const strike1 = terms.strikePrice1; + const strike2 = terms.strikePrice2; + const longShare = calculateLongShare(strikeMath, price, strike1, strike2); + // either offer might be exercised late, so we pay the two seats separately. + reallocateToSeat(Position.LONG, longShare); + reallocateToSeat(Position.SHORT, inverse(longShare)); + } + + function schedulePayoffs() { + E(terms.priceAuthority) + .priceAtTime( + terms.timer, + terms.expiration, + terms.underlyingAmount, + strikeBrand, + ) + .then(priceQuote => payoffOptions(priceQuote.quoteAmount)); + } + + function makeOptionInvitation(dir) { + // All we do at time of exercise is resolve the promise. + return zcf.makeInvitation( + seat => seatPromiseKits[dir].resolve(seat), + `collect ${dir} payout`, + { position: dir }, + ); + } + + async function makeCreatorInvitation() { + const pair = { + LongOption: makeOptionInvitation(Position.LONG), + ShortOption: makeOptionInvitation(Position.SHORT), + }; + const invitationIssuer = zcf.getInvitationIssuer(); + const longAmount = await E(invitationIssuer).getAmountOf(pair.LongOption); + const shortAmount = await E(invitationIssuer).getAmountOf(pair.ShortOption); + const amounts = { LongOption: longAmount, ShortOption: shortAmount }; + await depositToSeat(zcf, collateralSeat, amounts, pair); + + // transfer collateral from creator to collateralSeat, then return a pair + // of callSpread options + /** @type {OfferHandler} */ + const createOptionsHandler = creatorSeat => { + assertProposalShape(creatorSeat, { + give: { Collateral: null }, + want: { LongOption: null, ShortOption: null }, + }); + + trade( + zcf, + { + seat: collateralSeat, + gains: { Collateral: terms.settlementAmount }, + }, + { + seat: creatorSeat, + gains: { LongOption: longAmount, ShortOption: shortAmount }, + }, + ); + schedulePayoffs(); + creatorSeat.exit(); + }; + + const custom = harden({ + longAmount, + shortAmount, + }); + return zcf.makeInvitation(createOptionsHandler, `call spread pair`, custom); + } + + return harden({ creatorInvitation: makeCreatorInvitation() }); +}; + +harden(start); +export { start, calculateLongShare }; diff --git a/packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js b/packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js new file mode 100644 index 00000000000..e4f19f39a1a --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/test-callSpread-calculation.js @@ -0,0 +1,78 @@ +// 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 '../../../exported'; + +import { setup } from '../setupBasicMints'; +import { calculateLongShare } from '../../../src/contracts/callSpread'; + +test('callSpread-calculation, at lower bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(20); + const strike2 = moola(70); + const price = moola(20); + t.is(0, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, at upper bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(20); + const strike2 = moola(55); + const price = moola(55); + t.is(100, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, below lower bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(55); + const price = moola(0); + t.is(0, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, above upper bound', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(55); + const price = moola(60); + t.is(100, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, mid-way', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(45); + const price = moola(40); + t.is(83, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, zero', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(45); + const price = moola(0); + t.is(0, calculateLongShare(moolaMath, price, strike1, strike2)); +}); + +test('callSpread-calculation, large', async t => { + const { moola, amountMaths } = setup(); + const moolaMath = amountMaths.get('moola'); + + const strike1 = moola(15); + const strike2 = moola(45); + const price = moola(10000000000); + t.is(100, calculateLongShare(moolaMath, price, strike1, strike2)); +}); 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 00000000000..a0e1e5f659e --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/test-callSpread.js @@ -0,0 +1,669 @@ +// 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 { makeIssuerKit, MathKind } from '@agoric/ertp'; +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( + underlyingAmountMath, + strikeAmountMath, + priceSchedule, +) { + const { + mint: quoteMint, + issuer: quoteIssuer, + amountMath: quote, + } = makeIssuerKit('quote', MathKind.SET); + + 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; + } + + function priceQuote(timer, currentTime, underlyingAmount) { + const underlyingValue = underlyingAmountMath.getValue(underlyingAmount); + const price = priceFromSchedule(currentTime); + const strikePrice = strikeAmountMath.make(price * underlyingValue); + const quoteAmount = quote.make( + harden([ + { + assetAmount: underlyingAmount, + price: strikePrice, + timer, + timestamp: currentTime, + }, + ]), + ); + return harden({ + quotePament: quoteMint.mintPayment(quoteAmount), + quoteAmount, + }); + } + + const priceAuthority = { + getQuoteIssuer: () => quoteIssuer, + priceAtTime: (timer, timeStamp, underlyingAmount) => { + const { promise, resolve } = makePromiseKit(); + + E(timer).setWakeup( + timeStamp, + harden({ + wake: time => { + return resolve(priceQuote(timer, time, 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); + + // 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(); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + 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), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Quote: priceAuthority.getQuoteIssuer(), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; + + 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 { + 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); + + // 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(); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + 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), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Quote: priceAuthority.getQuoteIssuer(), + }); + + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; + + 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 { + 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); + + // 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(); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + 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), + timer: manualTimer, + }); + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Quote: priceAuthority.getQuoteIssuer(), + }); + + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; + + 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 { + 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); + + // 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(); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + 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), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Quote: priceAuthority.getQuoteIssuer(), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const invitationDetails = await E(zoe).getInvitationDetails( + creatorInvitation, + ); + const aliceProposal = harden({ + want: { + LongOption: invitationDetails.longAmount, + ShortOption: invitationDetails.shortAmount, + }, + give: { Collateral: bucks(300) }, + }); + const alicePayments = { Collateral: aliceBucksPayment }; + const aliceSeat = await zoe.offer( + creatorInvitation, + aliceProposal, + alicePayments, + ); + 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)); + + const manualTimer = buildManualTimer(console.log, 1); + const priceAuthority = makeFakePriceAuthority( + 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), + timer: manualTimer, + }); + + // Alice creates a callSpread instance + const issuerKeywordRecord = harden({ + Underlying: simoleanIssuer, + Collateral: bucksIssuer, + Strike: moolaIssuer, + Quote: priceAuthority.getQuoteIssuer(), + }); + const { creatorInvitation } = await zoe.startInstance( + installation, + issuerKeywordRecord, + terms, + ); + + const invitationDetail = await E(zoe).getInvitationDetails(creatorInvitation); + const longOptionAmount = invitationDetail.longAmount; + const shortOptionAmount = invitationDetail.shortAmount; + + 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 { + 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), + ); + + // Carol 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 cc962016104..f020a02bd7a 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`, - ); - }); + ), + ); }); };