@agoric/zoe
Version:
Zoe: the Smart Contract Framework for Offer Enforcement
640 lines (572 loc) • 20.1 kB
JavaScript
import { AmountMath } from '@agoric/ertp';
import { assertAllDefined } from '@agoric/internal';
import { notForProductionUse } from '@agoric/internal/src/magic-cookie-test-only.js';
import {
makeNotifierKit,
makeStoredPublishKit,
observeNotifier,
} from '@agoric/notifier';
import { makeLegacyMap } from '@agoric/store';
import { TimeMath } from '@agoric/time';
import { Fail, q } from '@endo/errors';
import { E } from '@endo/eventual-send';
import { Far } from '@endo/marshal';
import {
addRatios,
assertParsableNumber,
calculateMedian,
ceilDivideBy,
floorMultiplyBy,
makeOnewayPriceAuthorityKit,
makePriceQuoteIssuer,
makeRatio,
makeRatioFromAmounts,
multiplyRatios,
parseRatio,
ratioGTE,
ratiosSame,
} from '../contractSupport/index.js';
/**
* @import {LegacyMap} from '@agoric/store'
* @import {ContractOf} from '../zoeService/utils.js';
* @import {PriceDescription, PriceQuote, PriceQuoteValue, PriceQuery,} from '@agoric/zoe/tools/types.js';
* @import {Invitation, ZCF, ZCFSeat} from '@agoric/zoe';
*/
/** @typedef {bigint | number | string} ParsableNumber */
/**
* @typedef {Readonly<ParsableNumber | { data: ParsableNumber }>} OraclePriceSubmission
*/
/** @typedef {ParsableNumber | Ratio} Price */
/** @type {(quote: PriceQuote) => PriceDescription} */
const priceDescriptionFromQuote = quote => quote.quoteAmount.value[0];
/**
* @deprecated use fluxAggregator
*
* This contract aggregates price values from a set of oracles and provides a
* PriceAuthority for their median. This naive method is game-able and so this module
* is a stub until we complete what is now in `fluxAggregatorKit.js`.
*
* @param {ZCF<{
* timer: import('@agoric/time').TimerService,
* POLL_INTERVAL: bigint,
* brandIn: Brand<'nat'>,
* brandOut: Brand<'nat'>,
* unitAmountIn: Amount<'nat'>,
* }>} zcf
* @param {{
* marshaller: Marshaller,
* quoteMint?: ERef<Mint<'set', PriceDescription>>,
* storageNode: ERef<StorageNode>,
* }} privateArgs
*/
const start = async (zcf, privateArgs) => {
notForProductionUse();
// brands come from named terms instead of `brands` key because the latter is
// a StandardTerm that Zoe creates from the `issuerKeywordRecord` argument and
// Oracle brands are inert (without issuers or mints).
const { timer, POLL_INTERVAL, brandIn, brandOut, unitAmountIn } =
zcf.getTerms();
assertAllDefined({ brandIn, brandOut, POLL_INTERVAL, timer, unitAmountIn });
assert(privateArgs, 'Missing privateArgs in priceAggregator start');
const { marshaller, storageNode } = privateArgs;
assertAllDefined({ marshaller, storageNode });
/** @type {ERef<Mint<'set', PriceDescription>>} */
const quoteMint =
privateArgs.quoteMint ||
// makeIssuerKit fails upgrade, this contract is for demo only
makePriceQuoteIssuer().mint;
const quoteIssuerRecord = await zcf.saveIssuer(
E(quoteMint).getIssuer(),
'Quote',
);
const quoteKit = {
...quoteIssuerRecord,
mint: quoteMint,
};
/** @type {Ratio} */
let lastPrice;
/**
*
* @param {PriceQuoteValue} quote
*/
const authenticateQuote = async quote => {
const quoteAmount = AmountMath.make(quoteKit.brand, harden(quote));
const quotePayment = await E(quoteKit.mint).mintPayment(quoteAmount);
return harden({ quoteAmount, quotePayment });
};
// To notify the price authority of a new median
const { notifier: medianNotifier, updater: medianUpdater } =
makeNotifierKit();
// For diagnostics
const { notifier: roundCompleteNotifier, updater: roundCompleteUpdater } =
makeNotifierKit();
// For publishing priceAuthority values to off-chain storage
const { publisher, subscriber } = makeStoredPublishKit(
storageNode,
marshaller,
);
const zoe = zcf.getZoeService();
/**
* @typedef {object} OracleRecord
* @property {(timestamp: import('@agoric/time').Timestamp) => Promise<void>} [querier]
* @property {Ratio} lastSample
* @property {OracleKey} oracleKey
*/
/** @type {Set<OracleRecord>} */
const oracleRecords = new Set();
/** @type {LegacyMap<OracleKey, Set<OracleRecord>>} */
// Legacy because we're storing a raw JS Set
const instanceToRecords = makeLegacyMap('oracleInstance');
let publishedTimestamp = await E(timer).getCurrentTimestamp();
const { timerBrand } = publishedTimestamp;
// Wake every POLL_INTERVAL and run the queriers.
const repeaterP = E(timer).makeRepeater(0n, POLL_INTERVAL);
/** @type {import('@agoric/time').TimerWaker} */
const waker = Far('waker', {
async wake(timestamp) {
// Run all the queriers.
const querierPs = [];
for (const { querier } of oracleRecords) {
if (querier) {
querierPs.push(querier(timestamp));
}
}
if (!querierPs.length) {
// Only have push results, so publish them.
querierPs.push(updateQuote(timestamp));
}
await Promise.all(querierPs).catch(console.error);
},
});
void E(repeaterP).schedule(waker);
/**
* @param {object} param0
* @param {Ratio} [param0.overridePrice]
* @param {import('@agoric/time').TimestampRecord} [param0.timestamp]
*/
const makeCreateQuote = ({ overridePrice, timestamp } = {}) =>
/**
* @param {PriceQuery} priceQuery
* @returns {ERef<PriceQuote> | undefined}
*/
function createQuote(priceQuery) {
// Use the current price.
const price = overridePrice || lastPrice;
if (price === undefined) {
// We don't have a quote, so abort.
return undefined;
}
/**
* @param {Amount<'nat'>} amountIn the given amountIn
* @returns {Amount<'nat'>} the amountOut that will be received
*/
const calcAmountOut = amountIn => {
return floorMultiplyBy(amountIn, price);
};
/**
* @param {Amount<'nat'>} amountOut the wanted amountOut
* @returns {Amount<'nat'>} the amountIn needed to give
*/
const calcAmountIn = amountOut => {
return ceilDivideBy(amountOut, price);
};
// Calculate the quote.
const quote = priceQuery(calcAmountOut, calcAmountIn);
if (!quote) {
return undefined;
}
const {
amountIn,
amountOut,
timestamp: theirTimestamp = timestamp,
} = quote;
AmountMath.coerce(brandIn, amountIn);
AmountMath.coerce(brandOut, amountOut);
if (theirTimestamp !== undefined) {
return authenticateQuote([
{ amountIn, amountOut, timer, timestamp: theirTimestamp },
]);
}
return E(timer)
.getCurrentTimestamp()
.then(now =>
authenticateQuote([{ amountIn, amountOut, timer, timestamp: now }]),
);
};
const { priceAuthority, adminFacet: priceAuthorityAdmin } =
makeOnewayPriceAuthorityKit({
createQuote: makeCreateQuote(),
notifier: medianNotifier,
quoteIssuer: quoteKit.issuer,
timer,
actualBrandIn: brandIn,
actualBrandOut: brandOut,
});
// for each new quote from the priceAuthority, publish it to off-chain storage
void observeNotifier(
priceAuthority.makeQuoteNotifier(unitAmountIn, brandOut),
{
updateState: quote => {
publisher.publish(priceDescriptionFromQuote(quote));
},
fail: reason => {
throw Error(`priceAuthority observer failed: ${reason}`);
},
finish: done => {
throw Error(`priceAuthority observer died: ${done}`);
},
},
);
/**
* @param {Ratio} r
* @param {bigint} n
*/
const divide = (r, n) =>
makeRatio(
r.numerator.value,
r.numerator.brand,
r.denominator.value * n,
r.denominator.brand,
);
/**
* @param {import('@agoric/time').Timestamp} timestamp
*/
const updateQuote = async timestamp => {
timestamp = TimeMath.coerceTimestampRecord(timestamp, timerBrand);
const submitted = [...oracleRecords.values()].map(
({ oracleKey, lastSample }) =>
/** @type {[OracleKey, Ratio]} */ ([oracleKey, lastSample]),
);
const median = calculateMedian(
submitted
.map(([_k, v]) => v)
.filter(
sample =>
sample.numerator.value > 0n && sample.denominator.value > 0n,
),
{
add: addRatios,
divide,
isGTE: ratioGTE,
},
);
if (median === undefined) {
return;
}
/** @type {PriceDescription} */
const quote = {
amountIn: median.denominator,
amountOut: median.numerator,
timer,
timestamp,
};
// Authenticate the quote by minting it with our quote issuer, then publish.
const authenticatedQuote = await authenticateQuote([quote]);
roundCompleteUpdater.updateState({ submitted, authenticatedQuote });
// Fire any triggers now; we don't care if the timestamp is fully ordered,
// only if the limit has ever been met.
await priceAuthorityAdmin.fireTriggers(
makeCreateQuote({ overridePrice: median, timestamp }),
);
// If a more recent timestamp has already been published, we are too late.
if (TimeMath.compareAbs(timestamp, publishedTimestamp) < 0) {
return;
}
if (lastPrice && ratiosSame(lastPrice, median)) {
// No change in the median so don't publish
return;
}
// Publish a new authenticated quote.
publishedTimestamp = TimeMath.coerceTimestampRecord(timestamp, timerBrand);
lastPrice = median;
medianUpdater.updateState(authenticatedQuote);
};
/** @param {ERef<Brand>} brand */
const getDecimalP = async brand => {
const displayInfo = E(brand).getDisplayInfo();
return E.get(displayInfo).decimalPlaces;
};
const [decimalPlacesIn = 0, decimalPlacesOut = 0] = await Promise.all([
getDecimalP(brandIn),
getDecimalP(brandOut),
]);
/**
* We typically don't rely on decimal places in contract code, but if an
* oracle price source supplies a single dimensionless price, we need to
* interpret it as a ratio for units of brandOut per units of brandIn. If we
* don't do this, then our quoted prices (i.e. `amountOut`) would not be
* correct for brands with different decimalPlaces.
*
* This isn't really an abuse: we are using these decimalPlaces correctly to
* interpret the price source's "display output" as an actual
* amountOut:amountIn ratio used in calculations.
*
* If a price source wishes to supply an amountOut:amountIn ratio explicitly,
* it is free to do so, and that is the preferred way. We leave this
* unitPriceScale implementation intact, however, since price sources may be
* outside the distributed object fabric and unable to convey brand references
* (since they can communicate only plain data).
*/
const unitPriceScale = makeRatio(
10n ** BigInt(Math.max(decimalPlacesOut - decimalPlacesIn, 0)),
brandOut,
10n ** BigInt(Math.max(decimalPlacesIn - decimalPlacesOut, 0)),
brandOut,
);
/**
*
* @param {ParsableNumber} numericData
* @returns {Ratio}
*/
const makeRatioFromData = numericData => {
const unscaled = parseRatio(numericData, brandOut, brandIn);
return multiplyRatios(unscaled, unitPriceScale);
};
/**
*
* @param {Notifier<OraclePriceSubmission>} oracleNotifier
* @param {number} scaleValueOut
* @param {(result: Ratio) => Promise<void>} pushResult
*/
const pushFromOracle = (oracleNotifier, scaleValueOut, pushResult) => {
// Support for notifiers that don't produce ratios, but need scaling for
// their numeric data.
const priceScale = parseRatio(scaleValueOut, brandOut);
/** @param {ParsableNumber} numericData */
const makeScaledRatioFromData = numericData => {
const ratio = makeRatioFromData(numericData);
return multiplyRatios(ratio, priceScale);
};
/**
* Adapt the notifier to push results.
*
* @param {UpdateRecord<OraclePriceSubmission>} param0
*/
const recurse = ({ value, updateCount }) => {
if (!oracleNotifier || !updateCount) {
// Interrupt the cycle because we either are deleted or the notifier
// finished.
return;
}
// Queue the next update.
void E(oracleNotifier).getUpdateSince(updateCount).then(recurse);
// See if we have associated parameters or just a raw value.
/** @type {Ratio | undefined} */
let result;
switch (typeof value) {
case 'number':
case 'bigint':
case 'string': {
result = makeScaledRatioFromData(value);
break;
}
case 'undefined': {
throw Fail`undefined value in OraclePriceSubmission`;
}
case 'object': {
if ('data' in value) {
result = makeScaledRatioFromData(value.data);
break;
}
if ('numerator' in value) {
// ratio
result = value;
break;
}
throw Fail`unknown value object`;
}
default: {
throw Fail`unknown value type ${q(typeof value)}`;
}
}
pushResult(result).catch(console.error);
};
// Start the notifier.
void E(oracleNotifier).getUpdateSince().then(recurse);
};
const creatorFacet = Far('PriceAggregatorCreatorFacet', {
/**
* An "oracle invitation" is an invitation to be able to submit data to
* include in the priceAggregator's results.
*
* The offer result from this invitation is a OracleAdmin, which can be used
* directly to manage the price submissions as well as to terminate the
* relationship.
*
* @param {Instance | string} [oracleKey]
*/
makeOracleInvitation: async oracleKey => {
/**
* If custom arguments are supplied to the `zoe.offer` call, they can
* indicate an OraclePriceSubmission notifier and a corresponding
* `shiftValueOut` that should be adapted as part of the priceAuthority's
* reported data.
*
* @param {ZCFSeat} seat
* @param {object} param1
* @param {Notifier<OraclePriceSubmission>} [param1.notifier] optional notifier that produces oracle price submissions
* @param {number} [param1.scaleValueOut]
* @returns {Promise<{admin: OracleAdmin<Price>, invitationMakers: {PushPrice: (price: ParsableNumber) => Promise<Invitation<void>>} }>}
*/
const offerHandler = async (
seat,
{ notifier: oracleNotifier, scaleValueOut = 1 } = {},
) => {
seat.exit();
const admin = await creatorFacet.initOracle(oracleKey);
const invitationMakers = Far('invitation makers', {
/** @param {ParsableNumber} price */
PushPrice(price) {
assertParsableNumber(price);
return zcf.makeInvitation(cSeat => {
cSeat.exit();
void admin.pushResult(price);
}, 'PushPrice');
},
});
if (oracleNotifier) {
pushFromOracle(oracleNotifier, scaleValueOut, r =>
admin.pushResult(r),
);
}
return harden({
admin: Far('oracleAdmin', {
...admin,
/** @override */
delete: async () => {
// Stop tracking the notifier on delete.
oracleNotifier = undefined;
return admin.delete();
},
}),
invitationMakers,
});
};
return zcf.makeInvitation(offerHandler, 'oracle invitation');
},
/** @param {OracleKey} oracleKey */
deleteOracle: async oracleKey => {
for (const record of instanceToRecords.get(oracleKey)) {
oracleRecords.delete(record);
}
// We should remove the entry entirely, as it is empty.
instanceToRecords.delete(oracleKey);
// Delete complete, so try asynchronously updating the quote.
const deletedNow = await E(timer).getCurrentTimestamp();
await updateQuote(deletedNow);
},
/**
* @param {Instance | string} [oracleInstance]
* @param {OracleQuery} [query]
* @returns {Promise<OracleAdmin<Price>>}
*/
initOracle: async (oracleInstance, query) => {
/** @type {OracleKey} */
const oracleKey = oracleInstance || Far('fresh key', {});
/** @type {OracleRecord} */
const record = {
oracleKey,
lastSample: makeRatio(0n, brandOut, 1n, brandIn),
};
/** @type {Set<OracleRecord>} */
let records;
if (instanceToRecords.has(oracleKey)) {
records = instanceToRecords.get(oracleKey);
} else {
records = new Set();
instanceToRecords.init(oracleKey, records);
}
records.add(record);
oracleRecords.add(record);
/**
* Push the current price ratio.
*
* @param {ParsableNumber | Ratio} result
*/
const pushResult = result => {
// Sample of NaN, 0, or negative numbers get culled in the median
// calculation.
/** @type {Ratio | undefined} */
let ratio;
if (typeof result === 'object') {
ratio = makeRatioFromAmounts(result.numerator, result.denominator);
AmountMath.coerce(brandOut, ratio.numerator);
AmountMath.coerce(brandIn, ratio.denominator);
} else {
ratio = makeRatioFromData(result);
}
record.lastSample = ratio;
};
/** @type {OracleAdmin<Price>} */
const oracleAdmin = Far('OracleAdmin', {
async delete() {
assert(records.has(record), 'Oracle record is already deleted');
// The actual deletion is synchronous.
oracleRecords.delete(record);
records.delete(record);
if (
records.size === 0 &&
instanceToRecords.has(oracleKey) &&
instanceToRecords.get(oracleKey) === records
) {
// We should remove the entry entirely, as it is empty.
instanceToRecords.delete(oracleKey);
}
// Delete complete, so try asynchronously updating the quote.
const deletedNow = await E(timer).getCurrentTimestamp();
await updateQuote(deletedNow);
},
async pushResult(result) {
// Sample of NaN, 0, or negative numbers get culled in
// the median calculation.
pushResult(result);
},
});
if (query === undefined || typeof oracleInstance === 'string') {
// They don't want to be polled.
return oracleAdmin;
}
// Obtain the oracle's publicFacet.
assert(oracleInstance);
const oracle =
/** @type {import('./oracle.js').OracleContract['publicFacet']} */ (
await E(zoe).getPublicFacet(oracleInstance)
);
assert(records.has(record), 'Oracle record is already deleted');
/** @type {import('@agoric/time').Timestamp} */
let lastWakeTimestamp = 0n;
/**
* @param {import('@agoric/time').Timestamp} timestamp
*/
record.querier = async timestamp => {
// Submit the query.
const result = await E(oracle).query(query);
assertParsableNumber(result);
// Now that we've received the result, check if we're out of date.
if (timestamp < lastWakeTimestamp || !records.has(record)) {
return;
}
lastWakeTimestamp = timestamp;
pushResult(result);
await updateQuote(timestamp);
};
const now = await E(timer).getCurrentTimestamp();
await record.querier(now);
// Return the oracle admin object.
return oracleAdmin;
},
});
const publicFacet = Far('publicFacet', {
getPriceAuthority() {
return priceAuthority;
},
getSubscriber: () => subscriber,
/** Diagnostic tool */
async getRoundCompleteNotifier() {
return roundCompleteNotifier;
},
});
return harden({ creatorFacet, publicFacet });
};
export { start };
/** @typedef {ContractOf<typeof start>} PriceAggregatorContract */