UNPKG

@agoric/zoe

Version:

Zoe: the Smart Contract Framework for Offer Enforcement

343 lines (316 loc) • 11.4 kB
import { Fail } from '@endo/errors'; import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; import { makePromiseKit } from '@endo/promise-kit'; import { AmountMath } from '@agoric/ertp'; import { makeNotifierFromAsyncIterable, makeNotifierKit, } from '@agoric/notifier'; import { TimeMath } from '@agoric/time'; import { makePriceQuoteIssuer, natSafeMath, } from '../src/contractSupport/index.js'; /** * @import {PriceAuthority, PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js'; */ const { coerceRelativeTimeRecord } = TimeMath; // 'if (a >= b)' becomes 'if (timestampGTE(a,b))' const timestampGTE = (a, b) => TimeMath.compareAbs(a, b) >= 0; // 'if (a <= b)' becomes 'if (timestampLTE(a,b))' const timestampLTE = (a, b) => TimeMath.compareAbs(a, b) <= 0; /** * @typedef {object} FakePriceAuthorityOptions * @property {Brand<'nat'>} actualBrandIn * @property {Brand<'nat'>} actualBrandOut * @property {Array<number>} [priceList] * @property {Array<[number, number]>} [tradeList] * @property {import('@agoric/time').TimerService} timer * @property {import('@agoric/time').RelativeTime} [quoteInterval] * @property {ERef<Mint<'set', PriceDescription>>} [quoteMint] * @property {Amount<'nat'>} [unitAmountIn] */ /** * TODO: multiple price Schedules for different goods, or for moving the price * in different directions? * * @param {FakePriceAuthorityOptions} options * @returns {Promise<PriceAuthority>} */ export async function makeFakePriceAuthority(options) { const { actualBrandIn, actualBrandOut, priceList, tradeList, timer, unitAmountIn = AmountMath.make(actualBrandIn, 1n), quoteInterval = 1n, quoteMint = makePriceQuoteIssuer().mint, } = options; tradeList || priceList || Fail`One of priceList or tradeList must be specified`; const unitValueIn = AmountMath.getValue(actualBrandIn, unitAmountIn); const comparisonQueue = []; let currentPriceIndex = 0; function currentTrade() { if (tradeList) { return tradeList[currentPriceIndex % tradeList.length]; } assert(priceList); return [unitValueIn, priceList[currentPriceIndex % priceList.length]]; } /** * @param {Brand} allegedBrandIn * @param {Brand} allegedBrandOut */ const assertBrands = (allegedBrandIn, allegedBrandOut) => { allegedBrandIn === actualBrandIn || Fail`${allegedBrandIn} is not an expected input brand`; allegedBrandOut === actualBrandOut || Fail`${allegedBrandOut} is not an expected output brand`; }; const quoteIssuer = E(quoteMint).getIssuer(); const quoteBrand = await E(quoteIssuer).getBrand(); /** * @type {NotifierRecord<import('@agoric/time').Timestamp>} * We need to have a notifier driven by the * TimerService because if the timer pushes updates to individual * QuoteNotifiers, we have a dependency inversion and the timer can never know * when the QuoteNotifier goes away. (Don't even mention WeakRefs... they're * not exposed to userspace under Swingset because they're nondeterministic.) * * TODO It would be desirable to add a timestamp notifier interface to the * TimerService https://github.com/Agoric/agoric-sdk/issues/2002 * * Caveat: even if we had a timestamp notifier, we can't use it for triggers * yet unless we rewrite our manualTimer tests not to depend on when exactly a * trigger has been fired for a given tick. */ const { notifier: ticker, updater: tickUpdater } = makeNotifierKit(); /** * * @param {Amount<'nat'>} amountIn * @param {Brand} brandOut * @param {import('@agoric/time').Timestamp} quoteTime * @returns {PriceQuote} */ function priceInQuote(amountIn, brandOut, quoteTime) { assertBrands(amountIn.brand, brandOut); AmountMath.coerce(actualBrandIn, amountIn); const [tradeValueIn, tradeValueOut] = currentTrade(); const valueOut = natSafeMath.floorDivide( natSafeMath.multiply(amountIn.value, tradeValueOut), tradeValueIn, ); /** @type {Amount<'set', PriceDescription>} */ const quoteAmount = AmountMath.make( quoteBrand, /** @type {[PriceDescription]} */ ( harden([ { amountIn, amountOut: AmountMath.make(actualBrandOut, valueOut), timer, timestamp: quoteTime, }, ]) ), ); const quote = harden({ quotePayment: E(quoteMint).mintPayment(quoteAmount), quoteAmount, }); return quote; } /** * @param {Brand<'nat'>} brandIn * @param {Amount<'nat'>} amountOut * @param {import('@agoric/time').Timestamp} quoteTime * @returns {PriceQuote} */ function priceOutQuote(brandIn, amountOut, quoteTime) { assertBrands(brandIn, amountOut.brand); const valueOut = AmountMath.getValue(actualBrandOut, amountOut); const [tradeValueIn, tradeValueOut] = currentTrade(); const valueIn = natSafeMath.ceilDivide( natSafeMath.multiply(valueOut, tradeValueIn), tradeValueOut, ); return priceInQuote( AmountMath.make(brandIn, valueIn), amountOut.brand, quoteTime, ); } // Keep track of the time of the latest price change. let latestTick; // clients who are waiting for a specific timestamp /** @type { [when: import('@agoric/time').Timestamp, resolve: (quote: PriceQuote) => void][] } */ let timeClients = []; // Check if a comparison request has been satisfied. // Returns true if it has, false otherwise. function checkComparisonRequest(req) { if (latestTick === undefined) { // We haven't got any quotes. return false; } const priceQuote = priceInQuote(req.amountIn, req.brandOut, latestTick); const { amountOut: quotedOut } = priceQuote.quoteAmount.value[0]; if (!req.operator(quotedOut)) { return false; } req.resolve(priceQuote); const reqIndex = comparisonQueue.indexOf(req); if (reqIndex >= 0) { comparisonQueue.splice(reqIndex, 1); } return true; } async function startTicker() { let firstTime = true; const handler = Far('wake handler', { wake: async t => { if (t === 0n) { // just in case makeRepeater() was called with delay=0, // which schedules an immediate wakeup return; } if (firstTime) { firstTime = false; } else { currentPriceIndex += 1; } latestTick = t; tickUpdater.updateState(t); const remainingTimeClients = []; for (const entry of timeClients) { const [when, resolve] = entry; if (timestampGTE(latestTick, when)) { resolve(latestTick); } else { remainingTimeClients.push(entry); } } timeClients = remainingTimeClients; for (const req of comparisonQueue) { checkComparisonRequest(req); } }, }); const timerBrand = await E(timer).getTimerBrand(); const soonRT = coerceRelativeTimeRecord(1n, timerBrand); const quoteIntervalRT = coerceRelativeTimeRecord(quoteInterval, timerBrand); const repeater = E(timer).makeRepeater(soonRT, quoteIntervalRT); return E(repeater).schedule(handler); } let tickListLength = 0; if (tradeList) { tickListLength = tradeList.length; } else if (priceList) { tickListLength = priceList.length; } if (tickListLength > 1) { // Only start the ticker if we have actual price changes. await startTicker(); } else if (tickListLength === 1) { // Constant price, so schedule it. const timestamp = await E(timer).getCurrentTimestamp(); tickUpdater.updateState(timestamp); latestTick = timestamp; } function resolveQuoteWhen(operator, amountIn, amountOutLimit) { assertBrands(amountIn.brand, amountOutLimit.brand); const promiseKit = makePromiseKit(); const req = { operator, amountIn, brandOut: amountOutLimit.brand, resolve: promiseKit.resolve, }; if (!checkComparisonRequest(req)) { comparisonQueue.push(req); } return promiseKit.promise; } async function* generateQuotes(amountIn, brandOut) { let record = await ticker.getUpdateSince(); while (record.updateCount !== undefined) { const { value: timestamp } = record; // = await E(timer).getCurrentTimestamp(); yield priceInQuote(amountIn, brandOut, timestamp); record = await ticker.getUpdateSince(record.updateCount); } } harden(generateQuotes); /** @type {PriceAuthority} */ const priceAuthority = Far('fake price authority', { getQuoteIssuer: (brandIn, brandOut) => { assertBrands(brandIn, brandOut); return quoteIssuer; }, getTimerService: (brandIn, brandOut) => { assertBrands(brandIn, brandOut); return timer; }, makeQuoteNotifier: async (amountIn, brandOut) => { assertBrands(amountIn.brand, brandOut); return makeNotifierFromAsyncIterable(generateQuotes(amountIn, brandOut)); }, quoteAtTime: (timeStamp, amountIn, brandOut) => { timeStamp = TimeMath.absValue(timeStamp); assert.typeof(timeStamp, 'bigint'); assertBrands(amountIn.brand, brandOut); if (latestTick && timestampLTE(timeStamp, latestTick)) { return Promise.resolve(priceInQuote(amountIn, brandOut, timeStamp)); } else { // follow ticker until it fires with >= timeStamp const { promise, resolve } = makePromiseKit(); timeClients.push([timeStamp, resolve]); return promise.then(ts => { return priceInQuote(amountIn, brandOut, ts); }); } }, quoteGiven: async (amountIn, brandOut) => { assertBrands(amountIn.brand, brandOut); const timestamp = await E(timer).getCurrentTimestamp(); return priceInQuote(amountIn, brandOut, timestamp); }, quoteWanted: async (brandIn, amountOut) => { assertBrands(brandIn, amountOut.brand); const timestamp = await E(timer).getCurrentTimestamp(); return priceOutQuote(brandIn, amountOut, timestamp); }, quoteWhenGTE: (amountIn, amountOutLimit) => { const compareGTE = amount => AmountMath.isGTE(amount, amountOutLimit); return resolveQuoteWhen(compareGTE, amountIn, amountOutLimit); }, quoteWhenGT: (amountIn, amountOutLimit) => { const compareGT = amount => !AmountMath.isGTE(amountOutLimit, amount); return resolveQuoteWhen(compareGT, amountIn, amountOutLimit); }, quoteWhenLTE: (amountIn, amountOutLimit) => { const compareLTE = amount => AmountMath.isGTE(amountOutLimit, amount); return resolveQuoteWhen(compareLTE, amountIn, amountOutLimit); }, quoteWhenLT: (amountIn, amountOutLimit) => { const compareLT = amount => !AmountMath.isGTE(amount, amountOutLimit); return resolveQuoteWhen(compareLT, amountIn, amountOutLimit); }, mutableQuoteWhenLT: () => { throw Error('use ScriptedPriceAuthority'); }, mutableQuoteWhenLTE: () => { throw Error('use ScriptedPriceAuthority'); }, mutableQuoteWhenGT: () => { throw Error('use ScriptedPriceAuthority'); }, mutableQuoteWhenGTE: () => { throw Error('use ScriptedPriceAuthority'); }, }); return priceAuthority; }