diff --git a/packages/ERTP/src/amountMath.js b/packages/ERTP/src/amountMath.js index fffabbe811d..490dd351e31 100644 --- a/packages/ERTP/src/amountMath.js +++ b/packages/ERTP/src/amountMath.js @@ -1,8 +1,14 @@ // @ts-check -import { assert, details } from '@agoric/assert'; +import { assert, details as d } from '@agoric/assert'; -import { mustBeComparable } from '@agoric/same-structure'; +import { + mustBeComparable, + patternKindOf, + STAR_PATTERN, + isGround, + matches, +} from '@agoric/same-structure'; import './types'; import natMathHelpers from './mathHelpers/natMathHelpers'; @@ -87,7 +93,7 @@ function makeAmountMath(brand, amountMathKind) { const helpers = mathHelpers[amountMathKind]; assert( helpers !== undefined, - details`unrecognized amountMathKind: ${amountMathKind}`, + d`unrecognized amountMathKind: ${amountMathKind}`, ); // Cache the amount if we can. @@ -105,6 +111,10 @@ function makeAmountMath(brand, amountMathKind) { * @returns {Amount} */ make: allegedValue => { + assert( + isGround(allegedValue), + d`allegedValue ${allegedValue} must not be a non-ground pattern`, + ); const value = helpers.doCoerce(allegedValue); const amount = harden({ brand, value }); cache.add(amount); @@ -126,11 +136,11 @@ function makeAmountMath(brand, amountMathKind) { const { brand: allegedBrand, value } = allegedAmount; assert( allegedBrand !== undefined, - details`The brand in allegedAmount ${allegedAmount} is undefined. Did you pass a value rather than an amount?`, + d`The brand in allegedAmount ${allegedAmount} is undefined. Did you pass a value rather than an amount?`, ); assert( brand === allegedBrand, - details`The brand in the allegedAmount ${allegedAmount} in 'coerce' didn't match the amountMath brand ${brand}.`, + d`The brand in the allegedAmount ${allegedAmount} in 'coerce' didn't match the amountMath brand ${brand}.`, ); // Will throw on inappropriate value return amountMath.make(value); @@ -181,11 +191,105 @@ function makeAmountMath(brand, amountMathKind) { amountMath.getValue(rightAmount), ), ), + + /** + * TODO explain. + * + * @param {ValuePattern} valuePattern + * @returns {AmountPattern} + */ + makePattern: valuePattern => { + mustBeComparable(valuePattern); + if (isGround(valuePattern)) { + return amountMath.make(valuePattern); + } + return harden({ brand, value: valuePattern }); + }, + + /** + * TODO explain. + * + * @returns {AmountPattern} + */ + makeStarPattern: () => { + return amountMath.makePattern(STAR_PATTERN); + }, + + /** + * TODO explain. + * + * @param {AmountPattern} allegedAmountPattern + * @returns {AmountPattern} or throws if invalid + */ + coercePattern: allegedAmountPattern => { + const { brand: allegedBrand, value: valuePattern } = allegedAmountPattern; + assert( + allegedBrand !== undefined, + d`alleged brand is undefined. Did you pass a value pattern rather than an amount pattern?`, + ); + assert( + brand === allegedBrand, + d`the brand in the allegedAmountPattern in 'coerce' didn't match the amountMath brand`, + ); + // Will throw on inappropriate value + return amountMath.makePattern(valuePattern); + }, + + // TODO explain. + getValuePattern: amountPattern => + amountMath.coercePattern(amountPattern).value, + + frugalSplit: (pattern, specimen) => { + if (isGround(pattern)) { + if (amountMath.isGTE(specimen, pattern)) { + return harden({ + matched: pattern, + change: amountMath.subtract(specimen, pattern), + }); + } + return undefined; + } + const patternKind = patternKindOf(pattern); + switch (patternKind) { + case '*': { + return harden({ + // eslint-disable-next-line no-use-before-define + matched: empty, + change: specimen, + }); + } + default: { + const split = helpers.doFrugalSplit( + amountMath.getValuePattern(pattern), + amountMath.getValue(specimen), + ); + if (split === undefined) { + return undefined; + } + const { matched: valueMatched, change: valueChange } = split; + const matched = amountMath.make(valueMatched); + const change = amountMath.make(valueChange); + assert( + matches(pattern, matched), + d`Internal: doFrugalSplit algorithm violated matching invariant`, + ); + assert( + amountMath.isEqual(amountMath.add(matched, change), specimen), + d`Internal: doFrugalSplit algorithm violated conservation invariant`, + ); + return harden({ matched, change }); + } + } + }, + satisfies: (pattern, specimen) => { + // TODO should somehow enable individual MathKinds to override for + // a more efficient test that gives the same answer. + return undefined !== amountMath.frugalSplit(pattern, specimen); + }, }); const empty = amountMath.make(helpers.doGetEmpty()); return amountMath; } harden(makeAmountMath); - export { makeAmountMath }; diff --git a/packages/ERTP/src/mathHelpers/natMathHelpers.js b/packages/ERTP/src/mathHelpers/natMathHelpers.js index 16611189819..64a8f3967e5 100644 --- a/packages/ERTP/src/mathHelpers/natMathHelpers.js +++ b/packages/ERTP/src/mathHelpers/natMathHelpers.js @@ -1,6 +1,7 @@ // @ts-check import Nat from '@agoric/nat'; +import { assert, details as d } from '@agoric/assert'; import '../types'; @@ -26,6 +27,10 @@ const natMathHelpers = harden({ doIsEqual: (left, right) => left === right, doAdd: (left, right) => Nat(left + right), doSubtract: (left, right) => Nat(left - right), + + doFrugalSplit: (pattern, _specimen) => { + throw assert.fail(d`Unexpected nat pattern ${pattern}`); + }, }); harden(natMathHelpers); diff --git a/packages/ERTP/src/mathHelpers/setMathHelpers.js b/packages/ERTP/src/mathHelpers/setMathHelpers.js index f924ed64916..4ae15534c6f 100644 --- a/packages/ERTP/src/mathHelpers/setMathHelpers.js +++ b/packages/ERTP/src/mathHelpers/setMathHelpers.js @@ -1,8 +1,9 @@ // @ts-check import { passStyleOf } from '@agoric/marshal'; -import { assert, details } from '@agoric/assert'; +import { assert, details as d } from '@agoric/assert'; import { sameStructure } from '@agoric/same-structure'; +import { doFrugalSplit } from './strSetMathHelpers'; import '../types'; @@ -43,7 +44,7 @@ const checkForDupes = buckets => { for (let j = i + 1; j < maybeMatches.length; j += 1) { assert( !sameStructure(maybeMatches[i], maybeMatches[j]), - details`value has duplicates: ${maybeMatches[i]} and ${maybeMatches[j]}`, + d`value has duplicates: ${maybeMatches[i]} and ${maybeMatches[j]}`, ); } } @@ -92,12 +93,13 @@ const setMathHelpers = harden({ right.forEach(rightElem => { assert( hasElement(leftBuckets, rightElem), - details`right element ${rightElem} was not in left`, + d`right element ${rightElem} was not in left`, ); }); const leftElemNotInRight = leftElem => !hasElement(rightBuckets, leftElem); return harden(left.filter(leftElemNotInRight)); }, + doFrugalSplit, }); harden(setMathHelpers); diff --git a/packages/ERTP/src/mathHelpers/strSetMathHelpers.js b/packages/ERTP/src/mathHelpers/strSetMathHelpers.js index 812f24b1adf..4eaa1a08676 100644 --- a/packages/ERTP/src/mathHelpers/strSetMathHelpers.js +++ b/packages/ERTP/src/mathHelpers/strSetMathHelpers.js @@ -1,15 +1,77 @@ // @ts-check import { passStyleOf } from '@agoric/marshal'; -import { assert, details } from '@agoric/assert'; +import { assert, details as d, q } from '@agoric/assert'; +import { patternKindOf, matches } from '@agoric/same-structure'; + +const { entries } = Object; const identity = harden([]); const checkForDupes = list => { const set = new Set(list); - assert(set.size === list.length, details`value has duplicates: ${list}`); + assert(set.size === list.length, d`value has duplicates: ${list}`); }; +/** + * If the pattern is an array of patterns, then we define frugal according + * to a first-fit rule, where the order of elements in the pattern and the + * specimen matter. The most frugal match happens when the pattern is ordered + * from most specific to most general. However, this is advice to the caller, + * not something we either enforce or normalize to. As a result, a frugalSplit + * can fail even if each of the sub patterns could have been matched with + * distinct elements. + * + * For each element of the specimen, from left to right, we see if + * it matches a sub pattern of pattern, from left to right. If so, + * that sub pattern is satisfied and removed from the sub patterns yet + * to be satisfied. The element is moved into matched or change accordingly. + * + * This algorithm is generic across mathKind values that represent a set + * as an array of elements, so that it can be reused by other mathKinds. + * + * @param {ValuePattern} pattern + * @param {Value} specimen + * @returns {ValueSplit | undefined} + */ +const doFrugalSplit = (pattern, specimen) => { + const patternKind = patternKindOf(pattern); + switch (patternKind) { + case undefined: { + if (!Array.isArray(pattern)) { + return undefined; + } + const hungryPatterns = [...pattern]; + const matched = []; + const change = []; + for (const element of specimen) { + let found = false; + for (const [iStr, subPattern] of entries(hungryPatterns)) { + if (matches(subPattern, element)) { + matched.push(element); + hungryPatterns.splice(+iStr, 1); + found = true; + break; + } + } + if (!found) { + change.push(element); + } + } + if (hungryPatterns.length >= 1) { + return undefined; + } + return harden({ matched, change }); + } + default: { + throw assert.fail(d`Unexpected patternKind ${q(patternKind)}`); + } + } +}; + +harden(doFrugalSplit); +export { doFrugalSplit }; + /** * Operations for arrays with unique string elements. More information * about these assets might be provided by some other mechanism, such as @@ -43,10 +105,7 @@ const strSetMathHelpers = harden({ doAdd: (left, right) => { const union = new Set(left); const addToUnion = elem => { - assert( - !union.has(elem), - details`left and right have same element ${elem}`, - ); + assert(!union.has(elem), d`left and right have same element ${elem}`); union.add(elem); }; right.forEach(addToUnion); @@ -58,10 +117,11 @@ const strSetMathHelpers = harden({ const allRemovedCorrectly = right.every(remove); assert( allRemovedCorrectly, - details`some of the elements in right (${right}) were not present in left (${left})`, + d`some of the elements in right (${right}) were not present in left (${left})`, ); return harden(Array.from(leftSet)); }, + doFrugalSplit, }); harden(strSetMathHelpers); diff --git a/packages/ERTP/src/types.js b/packages/ERTP/src/types.js index ff01e9d8125..3be4314305e 100644 --- a/packages/ERTP/src/types.js +++ b/packages/ERTP/src/types.js @@ -12,6 +12,9 @@ */ /** + * TODO Want to say typedef {Object & Ground} Amount, but then Amount seems to + * be typed as "any" + * * @typedef {Object} Amount * Amounts are descriptions of digital assets, answering the questions * "how much" and "of what kind". Amounts are values labeled with a brand. @@ -27,27 +30,55 @@ */ /** - * @typedef {any} Value + * @typedef {Ground} Value * Values describe the value of something that can be owned or shared. * Fungible values are normally represented by natural numbers. Other * values may be represented as strings naming a particular right, or * an arbitrary object that sensibly represents the rights at issue. * - * Value must be Comparable. (Would be nice to type this correctly.) + * Value must be Comparable. + */ + +/** + * TODO Want to say typedef {Object & Pattern} AmountPattern, but then + * AmountPattern seems to be typed as "any" + * + * @typedef {Object} AmountPattern + * TODO explain + * + * @property {Brand} brand + * @property {ValuePattern} value + */ + +/** + * @typedef {Value & Pattern} ValuePattern + * TODO explain */ /** * @typedef {'nat' | 'set' | 'strSet'} AmountMathKind */ +/** + * @typedef AmountSplit + * @property {Amount} matched + * @property {Amount} change + */ + +/** + * @typedef ValueSplit + * @property {Value} matched + * @property {Value} change + */ + /** * @typedef {Object} AmountMath * Logic for manipulating amounts. * * Amounts are the canonical description of tradable goods. They are manipulated - * by issuers and mints, and represent the goods and currency carried by purses and - * payments. They can be used to represent things like currency, stock, and the - * abstract right to participate in a particular exchange. + * by issuers and mints, and represent the goods and currency carried by purses + * and payments. They can be used to represent things like currency, stock, and + * the abstract right to participate in a particular exchange. * * @property {() => Brand} getBrand Return the brand. * @property {() => AmountMathKind} getAmountMathKind @@ -95,6 +126,32 @@ * (subtraction results in a negative), throw an error. Because the * left amount must include the right amount, this is NOT equivalent * to set subtraction. + * + * + * @property {(valuePattern: ValuePattern) => AmountPattern} makePattern + * TODO explain + * + * @property {() => AmountPattern} makeStarPattern + * TODO explain + * + * @property {(allegedAmountPattern: AmountPattern) => AmountPattern} coercePattern + * TODO explain + * + * @property {(amountPattern: AmountPattern) => ValuePattern} getValuePattern + * TODO explain + * + * @property {(pattern: AmountPattern, specimen: Amount) => AmountSplit|undefined} frugalSplit + * Try to find a smallish portion of the specimen that matches the pattern. + * The smaller the better. If successful, return an AmountSplit with + * the matched portion in `matched` and the remainder in `change`. The + * specific deterministic rules will depend on a given MathKind. + * + * @property {(pattern: AmountPattern, specimen: Amount) => boolean} satisfies + * The specimen satisfies the pattern if some portion of the specimen + * matches the pattern. The result should be equivalent to testing whether + * `frugalSplit` return an AmountSplit rather than undefined. + * When `frugalSplit` would throw, `satisfies`` should throw, to preserve that + * equivalence. */ /** @@ -335,6 +392,15 @@ * @property {(left: Value, right: Value) => Value} doSubtract * Return what remains after removing the right from the left. If * something in the right was not in the left, we throw an error. + * + * @property {(pattern: ValuePattern, specimen: Value) => ValueSplit|undefined} doFrugalSplit + * Try to find a smallish portion of the specimen that matches the pattern. + * The smaller the better. If successful, return a ValueSplit with + * the matched portion in `matched` and the remainder in `change`. The + * specific deterministic rules will depend on a given MathKind. + * Note that `amountMath.frugalSplit` already takes care of the case where + * `isGround(pattern)` and the `'*'` case, so each `doFrugalSplit` method only + * needs to take care of the non-ground non-top-level-star cases. */ /** diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js index 53a43339619..0a1042a60ba 100644 --- a/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js +++ b/packages/ERTP/test/unitTests/mathHelpers/test-natMathHelpers.js @@ -1,5 +1,6 @@ import test from 'ava'; +import { PATTERN } from '@agoric/same-structure'; import { makeAmountMath, MathKind } from '../../../src'; // The "unit tests" for MathHelpers actually make the calls through @@ -13,7 +14,7 @@ const mockBrand = harden({ const amountMath = makeAmountMath(mockBrand, MathKind.NAT); -test('natMathHelpers', t => { +test('natMathHelpers ground', t => { const { getBrand, getAmountMathKind, @@ -115,3 +116,26 @@ test('natMathHelpers', t => { // subtract t.deepEqual(subtract(make(6), make(1)), make(5), `6 - 1 = 5`); }); + +test('natMathHelpers patterns', t => { + const { + makePattern, + makeStarPattern, + // coercePattern, TODO + // getValuePattern, TODO + // frugalSplit, TODO + // satisfies, TODO + } = amountMath; + + // makePattern + t.deepEqual(makePattern(harden({ [PATTERN]: '*' })), { + brand: mockBrand, + value: { [PATTERN]: '*' }, + }); + + // makeStarPattern + t.deepEqual(makeStarPattern(), { + brand: mockBrand, + value: { [PATTERN]: '*' }, + }); +}); diff --git a/packages/same-structure/index.js b/packages/same-structure/index.js index d6eac589bbe..20865bede13 100644 --- a/packages/same-structure/index.js +++ b/packages/same-structure/index.js @@ -4,3 +4,10 @@ export { mustBeSameStructure, mustBeComparable, } from './src/sameStructure'; +export { + PATTERN, + STAR_PATTERN, + patternKindOf, + matches, + isGround, +} from './src/match'; diff --git a/packages/same-structure/src/match.js b/packages/same-structure/src/match.js new file mode 100644 index 00000000000..2957184dfac --- /dev/null +++ b/packages/same-structure/src/match.js @@ -0,0 +1,175 @@ +// @ts-check + +import { sameValueZero, passStyleOf, REMOTE_STYLE } from '@agoric/marshal'; +import { assert, details as d, q } from '@agoric/assert'; + +import './types'; + +const { values } = Object; + +/** + * Special property name within a copyRecord that marks the copyRecord as + * representing a non-literal pattern record. See `isGround`. + */ +const PATTERN_KIND = '@pattern'; + +/** + * A pattern record that matches any specimen, i.e., a wildcard. + */ +const STAR_PATTERN = harden({ [PATTERN_KIND]: '*' }); + +/** + * If `passable` is a pattern record, return the string identifying what kind + * of pattern this pattern record represents. Else return undefined. + * + * @param {Passable} passable + * @returns {PatternKind | undefined} + */ +const patternKindOf = passable => { + const passStyle = passStyleOf(passable); + if (passStyle === 'copyRecord' && PATTERN_KIND in passable) { + const patternKind = passable[PATTERN_KIND]; + assert.string(patternKind); + return patternKind; + } + return undefined; +}; +harden(patternKindOf); + +/** + * A *passable* object is a pass-by-copy superstructure ending in + * non-pass-by-copy leaves, each of which is either a promise or a + * REMOTE_STYLE. A passable object in which none of the leaves are promises + * is a *comparable*. A comparable object in which none of the + * copyRecords are pattern records is *ground*. All other comparables are + * *non-ground*. + * + * Only some contexts care about the distinction between ground and non-ground, + * such as the arguments to the `match` function below. For most other purposes, + * these are simply passable and comparable objects. However, some uses of + * `sameStructure` to compare comparables should probably either be guarded by + * `isGround` tests or converted to `match` calls. + * + * @param {Passable} passable + * @returns {boolean} + */ +function isGround(passable) { + const passStyle = passStyleOf(passable); + switch (passStyle) { + case 'null': + case 'undefined': + case 'string': + case 'boolean': + case 'number': + case 'bigint': + case REMOTE_STYLE: + case 'copyError': { + return true; + } + case 'promise': { + return false; + } + case 'copyArray': { + return passable.every(isGround); + } + case 'copyRecord': { + const patternKind = patternKindOf(passable); + if (patternKind !== undefined) { + return false; + } + return values(passable).every(isGround); + } + default: { + throw new TypeError(`unrecognized passStyle ${passStyle}`); + } + } +} +harden(isGround); + +/** + * @param {Pattern} outerPattern + * @param {Ground} outerSpecimen + * @returns {boolean} + */ +function matches(outerPattern, outerSpecimen) { + assert(isGround(outerSpecimen), d`Can only match against ground comparables`); + + // TODO Reduce redundancy with sameStructure. + function matchesInternal(pattern, specimen) { + const patternKind = patternKindOf(pattern); + switch (patternKind) { + case undefined: { + const patternStyle = passStyleOf(pattern); + const specimenStyle = passStyleOf(specimen); + assert( + patternStyle !== 'promise', + d`Cannot structurally compare promises: ${pattern}`, + ); + assert( + specimenStyle !== 'promise', + d`Cannot structurally compare promises: ${specimen}`, + ); + + if (patternStyle !== specimenStyle) { + return false; + } + switch (patternStyle) { + case 'null': + case 'undefined': + case 'string': + case 'boolean': + case 'number': + case 'bigint': + case REMOTE_STYLE: { + return sameValueZero(pattern, specimen); + } + case 'copyRecord': + case 'copyArray': { + const leftNames = Object.getOwnPropertyNames(pattern); + const rightNames = Object.getOwnPropertyNames(specimen); + if (leftNames.length !== rightNames.length) { + return false; + } + for (const name of leftNames) { + // TODO: Better hasOwnProperty check + if (!Object.getOwnPropertyDescriptor(specimen, name)) { + return false; + } + // TODO: Make cycle tolerant + if (!matchesInternal(pattern[name], specimen[name])) { + return false; + } + } + return true; + } + case 'copyError': { + return ( + pattern.name === specimen.name && + pattern.message === specimen.message + ); + } + default: { + throw new TypeError(`unrecognized passStyle ${patternStyle}`); + } + } + } + case '*': { + // wildcard. matches anything. + return true; + } + default: { + throw assert.fail(d`unrecognized pattern kind ${q(patternKind)}`); + } + } + } + return matchesInternal(outerPattern, outerSpecimen); +} +harden(matches); + +export { + PATTERN_KIND as PATTERN, + STAR_PATTERN, + patternKindOf, + matches, + isGround, +}; diff --git a/packages/same-structure/src/sameStructure.js b/packages/same-structure/src/sameStructure.js index fbad09c7e6a..7b97859dfc7 100644 --- a/packages/same-structure/src/sameStructure.js +++ b/packages/same-structure/src/sameStructure.js @@ -1,8 +1,12 @@ // @ts-check +// @ts-check + import { sameValueZero, passStyleOf, REMOTE_STYLE } from '@agoric/marshal'; import { assert, details, q } from '@agoric/assert'; +import './types'; + // Shim of Object.fromEntries from // https://github.com/tc39/proposal-object-from-entries/blob/master/polyfill.js // TODO reconcile and dedup with the Object.fromEntries ponyfill in diff --git a/packages/same-structure/src/types.js b/packages/same-structure/src/types.js new file mode 100644 index 00000000000..dd039eaf6e8 --- /dev/null +++ b/packages/same-structure/src/types.js @@ -0,0 +1,35 @@ +/** + * @typedef {"*" | string} PatternKind + */ + +/** + * @typedef {Comparable} PatternRecord + * + * A pass-by-copy record with a property named '@pattern' (aka `PATTERN_KIND`) + * whose value is a PatternKind. + * TODO How do I declare a property whose name is not an indentifier? + * TODO For each specified PatternKind, we should declare here what that + * specific pattern record looks like. For example, the `'*'` pattern is + * otherwise empty. + */ + +/** + * @typedef {Comparable} Ground + * + * A Comparable is Ground if its pass-by-copy superstructure has no + * PatternRecords. Therefore, a Passable is only Ground if it has + * neither PatternRecords nor Promises. + */ + +/** + * @typedef {Comparable} Pattern + * + * We say that a Comparable is a Pattern when it is used in a context + * sensitive to whether it is ground. + * + * In these contexts, a Pattern represents the abstract set of Ground + * Comparables that match it. If the Pattern is itself Ground, then it matches + * only Ground Comparables that are `sameStructure` equivalent to it. If + * the Pattern is non-Ground, then it matches or not according to + * the Pattern's embedded PatternRecords. + */ diff --git a/packages/zoe/src/cleanProposal.js b/packages/zoe/src/cleanProposal.js index 7a48bd139cc..8f096cd7e0f 100644 --- a/packages/zoe/src/cleanProposal.js +++ b/packages/zoe/src/cleanProposal.js @@ -51,7 +51,7 @@ const cleanKeys = (allowedKeys, record) => { export const getKeywords = keywordRecord => harden(Object.getOwnPropertyNames(keywordRecord)); -export const coerceAmountKeywordRecord = ( +const coerceAmountKeywordRecord = ( getAmountMath, allegedAmountKeywordRecord, ) => { @@ -69,6 +69,24 @@ export const coerceAmountKeywordRecord = ( return arrayToObj(coercedAmounts, keywords); }; +const coerceAmountPatternKeywordRecord = ( + getAmountMath, + allegedAmountPatternKeywordRecord, +) => { + const keywords = getKeywords(allegedAmountPatternKeywordRecord); + keywords.forEach(assertKeywordName); + + const amountPatterns = Object.values(allegedAmountPatternKeywordRecord); + // Check that each pattern can be coerced using the amountMath + // indicated by brand. `AmountMath.coercePattern` throws if coercion fails. + const coercedAmountpatterns = amountPatterns.map(amountPattern => + getAmountMath(amountPattern.brand).coercePattern(amountPattern), + ); + + // Recreate the amountPatternKeywordRecord with coercedAmountPatterns. + return arrayToObj(coercedAmountpatterns, keywords); +}; + export const cleanKeywords = keywordRecord => { // `getOwnPropertyNames` returns all the non-symbol properties // (both enumerable and non-enumerable). @@ -112,7 +130,7 @@ export const cleanProposal = (getAmountMath, proposal) => { let { want = harden({}), give = harden({}) } = proposal; const { exit = harden({ onDemand: null }) } = proposal; - want = coerceAmountKeywordRecord(getAmountMath, want); + want = coerceAmountPatternKeywordRecord(getAmountMath, want); give = coerceAmountKeywordRecord(getAmountMath, give); // Check exit diff --git a/packages/zoe/src/contractFacet/offerSafety.js b/packages/zoe/src/contractFacet/offerSafety.js index 69152a72bc2..4e98b4229fe 100644 --- a/packages/zoe/src/contractFacet/offerSafety.js +++ b/packages/zoe/src/contractFacet/offerSafety.js @@ -6,21 +6,21 @@ * keyword of giveOrWant? * * @param {(brand: Brand) => AmountMath} getAmountMath - * @param {AmountKeywordRecord} giveOrWant + * @param {AmountPatternKeywordRecord} giveOrWant * @param {AmountKeywordRecord} allocation */ const satisfiesInternal = (getAmountMath, giveOrWant = {}, allocation) => { - const isGTEByKeyword = ([keyword, requiredAmount]) => { + const satisfiesByKeyword = ([keyword, requiredAmountPattern]) => { // If there is no allocation for a keyword, we know the giveOrWant // is not satisfied without checking further. if (allocation[keyword] === undefined) { return false; } - const amountMath = getAmountMath(requiredAmount.brand); + const amountMath = getAmountMath(requiredAmountPattern.brand); const allocationAmount = allocation[keyword]; - return amountMath.isGTE(allocationAmount, requiredAmount); + return amountMath.satisfies(requiredAmountPattern, allocationAmount); }; - return Object.entries(giveOrWant).every(isGTEByKeyword); + return Object.entries(giveOrWant).every(satisfiesByKeyword); }; /** diff --git a/packages/zoe/src/contractSupport/zoeHelpers.js b/packages/zoe/src/contractSupport/zoeHelpers.js index 1643b235d0f..14281f362ff 100644 --- a/packages/zoe/src/contractSupport/zoeHelpers.js +++ b/packages/zoe/src/contractSupport/zoeHelpers.js @@ -1,8 +1,8 @@ // @ts-check import '../../exported'; -import { assert, details } from '@agoric/assert'; -import { sameStructure } from '@agoric/same-structure'; +import { assert, details, quote as q } from '@agoric/assert'; +import { sameStructure, isGround } from '@agoric/same-structure'; import { E } from '@agoric/eventual-send'; import { makePromiseKit } from '@agoric/promise-kit'; @@ -15,6 +15,12 @@ export const defaultAcceptanceMsg = `The offer has been accepted. Once the contr const getKeysSorted = obj => harden(Object.getOwnPropertyNames(obj || {}).sort()); +/** + * @typedef FromToAllocations + * @property {Allocation} from + * @property {Allocation} to + */ + /** * Given toGains (an AmountKeywordRecord), and allocations (a pair, * 'to' and 'from', of Allocations), all the entries in @@ -32,10 +38,6 @@ const getKeysSorted = obj => * toGains. Note that the total amounts should always be equal; it * is the keywords that might be different. * @returns {FromToAllocations} allocations - new allocations - * - * @typedef FromToAllocations - * @property {Allocation} from - * @property {Allocation} to */ const calcNewAllocations = ( zcf, @@ -152,11 +154,12 @@ export const trade = ( left.losses, )); } catch (err) { - const newErr = new Error( - `The trade between left ${left} and right ${right} failed.`, + // TODO consider revising once we can use assert.error + throw assert.fail( + details`The trade between left ${q(left)} and right ${q( + right, + )} failed due to ${err}.`, ); - assert.note(newErr, details`due to ${err}`); - throw newErr; } // Check whether reallocate would error before calling. If @@ -178,8 +181,10 @@ export const trade = ( if (!offerSafeForRight) { console.log(`offer not safe for right`); } - throw new Error( - `The trade between left ${left} and right ${right} failed offer safety. Please check the log for more information`, + assert.fail( + details`The trade between left ${q(left)} and right ${q( + right, + )} failed offer safety. Please check the log for more information`, ); } @@ -195,6 +200,57 @@ export const trade = ( } }; +/** + * @param {ContractFacet} zcf + * @param {ZCFSeat} fromSeat + * @param {ZCFSeat} toSeat + * @param {string} fromHasExitedMsg + * @param {string} toHasExitedMsg + * @returns {AmountKeywordRecord} + */ +const findFromOtherSeat = ( + zcf, + fromSeat, + toSeat, + fromHasExitedMsg, + toHasExitedMsg, +) => { + assert(!fromSeat.hasExited(), fromHasExitedMsg); + assert(!toSeat.hasExited(), toHasExitedMsg); + /** @type {AmountPatternKeywordRecord} */ + const amountPatternKeywordRecord = toSeat.getProposal().want; + /** @type {AmountKeywordRecord} */ + const fromSeatAmountKeywordRecord = fromSeat.getCurrentAllocation(); + + /** + * @param {[Keyword, AmountPattern]} pair + * @returns {[Keyword, Amount]} + */ + const findAmountPair = pair => { + const [keyword, amountPattern] = pair; + const fromSeatAmount = fromSeatAmountKeywordRecord[keyword]; + if (fromSeatAmount === undefined) { + assert( + isGround(amountPattern), + details`Unmatched wants must be ground ${amountPattern}`, + ); + // A ground AmountPattern is a valid Amount + return [keyword, amountPattern]; + } + const amountMath = zcf.getAmountMath(amountPattern.brand); + const split = amountMath.frugalSplit(amountPattern, fromSeatAmount); + // If we are trying to transfer an amount but can't find what + // should be transferred, we should throw + assert( + split !== undefined, + details`The trade between fromSeat ${fromSeat} and toSeat ${toSeat} failed because ${amountPattern} was not found.`, + ); + return [keyword, split.matched]; + }; + + return objectMap(amountPatternKeywordRecord, findAmountPair); +}; + /** @type {Swap} */ export const swap = ( zcf, @@ -208,11 +264,23 @@ export const swap = ( zcf, { seat: leftSeat, - gains: leftSeat.getProposal().want, + gains: findFromOtherSeat( + zcf, + rightSeat, + leftSeat, + rightHasExitedMsg, + leftHasExitedMsg, + ), }, { seat: rightSeat, - gains: rightSeat.getProposal().want, + gains: findFromOtherSeat( + zcf, + leftSeat, + rightSeat, + leftHasExitedMsg, + rightHasExitedMsg, + ), }, leftHasExitedMsg, rightHasExitedMsg, @@ -247,12 +315,24 @@ export const swapExact = ( zcf, { seat: leftSeat, - gains: leftSeat.getProposal().want, + gains: findFromOtherSeat( + zcf, + rightSeat, + leftSeat, + rightHasExitedMsg, + leftHasExitedMsg, + ), losses: leftSeat.getProposal().give, }, { seat: rightSeat, - gains: rightSeat.getProposal().want, + gains: findFromOtherSeat( + zcf, + leftSeat, + rightSeat, + leftHasExitedMsg, + rightHasExitedMsg, + ), losses: rightSeat.getProposal().give, }, leftHasExitedMsg, diff --git a/packages/zoe/src/contracts/auction/assertBidSeat.js b/packages/zoe/src/contracts/auction/assertBidSeat.js index c0852faacae..86bb88d3455 100644 --- a/packages/zoe/src/contracts/auction/assertBidSeat.js +++ b/packages/zoe/src/contracts/auction/assertBidSeat.js @@ -4,7 +4,7 @@ export const assertBidSeat = (zcf, sellSeat, bidSeat) => { const { maths: { Ask: bidMath, Asset: assetMath }, } = zcf.getTerms(); - const minBid = sellSeat.getProposal().want.Ask; + const minBid = sellSeat.getProposal().want.Ask; // TODO const bid = bidSeat.getAmountAllocated('Bid', minBid.brand); assert( bidMath.isGTE(bid, minBid), diff --git a/packages/zoe/src/contracts/auction/secondPriceAuction.js b/packages/zoe/src/contracts/auction/secondPriceAuction.js index 0e464bb3fb4..cdac7e1e215 100644 --- a/packages/zoe/src/contracts/auction/secondPriceAuction.js +++ b/packages/zoe/src/contracts/auction/secondPriceAuction.js @@ -70,7 +70,7 @@ const start = zcf => { const customProperties = harden({ auctionedAssets: sellSeat.getProposal().give.Asset, - minimumBid: sellSeat.getProposal().want.Ask, + minimumBid: sellSeat.getProposal().want.Ask, // TODO closesAfter, timeAuthority, }); diff --git a/packages/zoe/src/contracts/auction/secondPriceLogic.js b/packages/zoe/src/contracts/auction/secondPriceLogic.js index 0262bfc24f1..b9a3beed465 100644 --- a/packages/zoe/src/contracts/auction/secondPriceLogic.js +++ b/packages/zoe/src/contracts/auction/secondPriceLogic.js @@ -1,7 +1,7 @@ export const calcWinnerAndClose = (zcf, sellSeat, bidSeats) => { const { give: { Asset: assetAmount }, - want: { Ask: minBid }, + want: { Ask: minBid }, // TODO } = sellSeat.getProposal(); const bidMath = zcf.getAmountMath(minBid.brand); const assetMath = zcf.getAmountMath(assetAmount.brand); diff --git a/packages/zoe/src/contracts/autoswap.js b/packages/zoe/src/contracts/autoswap.js index 2b33db4dcef..48799c16052 100644 --- a/packages/zoe/src/contracts/autoswap.js +++ b/packages/zoe/src/contracts/autoswap.js @@ -139,7 +139,7 @@ const start = async zcf => { const { give: { In: amountIn }, - want: { Out: wantedAmountOut }, + want: { Out: wantedAmountOut }, // TODO } = swapSeat.getProposal(); const outputValue = getInputPrice( @@ -168,7 +168,7 @@ const start = async zcf => { const { give: { In: amountIn }, - want: { Out: wantedAmountOut }, + want: { Out: wantedAmountOut }, // TODO } = swapSeat.getProposal(); const tradePrice = getOutputPrice( diff --git a/packages/zoe/src/contracts/barterExchange.js b/packages/zoe/src/contracts/barterExchange.js index a55aa1c9683..1996f350edc 100644 --- a/packages/zoe/src/contracts/barterExchange.js +++ b/packages/zoe/src/contracts/barterExchange.js @@ -13,7 +13,7 @@ import { trade, satisfies } from '../contractSupport'; * https://agoric.com/documentation/zoe/guide/contracts/barter-exchange.html * * The Barter Exchange only accepts offers that look like - * { give: { In: amount }, want: { Out: amount} } + * { give: { In: amount }, want: { Out: amount} } // TODO * The want amount will be matched, while the give amount is a maximum. Each * successful trader gets their `want` and may trade with counter-parties who * specify any amount up to their specified `give`. @@ -105,7 +105,7 @@ const start = zcf => { function extractOfferDetails(seat) { const { give: { In: amountIn }, - want: { Out: amountOut }, + want: { Out: amountOut }, // TODO } = seat.getProposal(); return { diff --git a/packages/zoe/src/contracts/coveredCall.js b/packages/zoe/src/contracts/coveredCall.js index 70e61dab7bf..e2d90a694b8 100644 --- a/packages/zoe/src/contracts/coveredCall.js +++ b/packages/zoe/src/contracts/coveredCall.js @@ -85,7 +85,7 @@ const start = zcf => { expirationDate: sellSeat.getProposal().exit.afterDeadline.deadline, timeAuthority: sellSeat.getProposal().exit.afterDeadline.timer, underlyingAssets: sellSeat.getProposal().give, - strikePrice: sellSeat.getProposal().want, + strikePrice: sellSeat.getProposal().want, // TODO }); return zcf.makeInvitation(exerciseOption, 'exerciseOption', customProps); }; diff --git a/packages/zoe/src/contracts/multipoolAutoswap/removeLiquidity.js b/packages/zoe/src/contracts/multipoolAutoswap/removeLiquidity.js index 066c93108e3..bf244b9cfc4 100644 --- a/packages/zoe/src/contracts/multipoolAutoswap/removeLiquidity.js +++ b/packages/zoe/src/contracts/multipoolAutoswap/removeLiquidity.js @@ -18,7 +18,7 @@ export const makeMakeRemoveLiquidityInvitation = (zcf, getPool) => { }, }); // Get the brand of the secondary token so we can identify the liquidity pool. - const secondaryBrand = seat.getProposal().want.Secondary.brand; + const secondaryBrand = seat.getProposal().want.Secondary.brand; // TODO const pool = getPool(secondaryBrand); return pool.removeLiquidity(seat); }; diff --git a/packages/zoe/src/contracts/multipoolAutoswap/swap.js b/packages/zoe/src/contracts/multipoolAutoswap/swap.js index 428661a65a4..3dff0765f4f 100644 --- a/packages/zoe/src/contracts/multipoolAutoswap/swap.js +++ b/packages/zoe/src/contracts/multipoolAutoswap/swap.js @@ -22,7 +22,7 @@ export const makeMakeSwapInvitation = ( }); const { give: { In: amountIn }, - want: { Out: wantedAmountOut }, + want: { Out: wantedAmountOut }, // TODO } = seat.getProposal(); const { brand: brandIn, value: inputValue } = amountIn; const brandOut = wantedAmountOut.brand; @@ -135,7 +135,7 @@ export const makeMakeSwapInvitation = ( // The offer's amountOut is exact; the offeredAmountIn is a max. const { give: { In: offeredAmountIn }, - want: { Out: amountOut }, + want: { Out: amountOut }, // TODO } = seat.getProposal(); const { brand: brandOut, value: outputValue } = amountOut; const brandIn = offeredAmountIn.brand; diff --git a/packages/zoe/src/contracts/sellItems.js b/packages/zoe/src/contracts/sellItems.js index 296a8ddeb27..121607ac4d5 100644 --- a/packages/zoe/src/contracts/sellItems.js +++ b/packages/zoe/src/contracts/sellItems.js @@ -69,7 +69,7 @@ const start = zcf => { const providedMoney = buyerSeat.getAmountAllocated('Money'); const { - want: { Items: wantedItems }, + want: { Items: wantedItems }, // TODO } = buyerSeat.getProposal(); // Check that the wanted items are still for sale. diff --git a/packages/zoe/src/objArrayConversion.js b/packages/zoe/src/objArrayConversion.js index 8315d6121da..e3c03634d41 100644 --- a/packages/zoe/src/objArrayConversion.js +++ b/packages/zoe/src/objArrayConversion.js @@ -43,7 +43,7 @@ export const assertSubset = (whole, part) => { /** * @template T, U - * @template {keyof T} K + * @template {string} K * @param {Record} original * @param {(pair: [K, T]) => [K, U]} mapPairFn * @returns {Record} diff --git a/packages/zoe/src/zoeService/types.js b/packages/zoe/src/zoeService/types.js index 0fd3eed92c2..a9f50215fb3 100644 --- a/packages/zoe/src/zoeService/types.js +++ b/packages/zoe/src/zoeService/types.js @@ -131,7 +131,7 @@ * @typedef {Partial} Proposal * * @typedef {{give: AmountKeywordRecord, - * want: AmountKeywordRecord, + * want: AmountPatternKeywordRecord, * exit: ExitRule * }} ProposalRecord */ @@ -143,6 +143,13 @@ * { Asset: amountMath.make(5), Price: amountMath.make(9) } */ +/** + * @typedef {Record} AmountPatternKeywordRecord + * + * The keys are keywords, and the values are amount patterns. For example: + * { Price: amountMath.makeOpPattern(9, '<=') } + */ + /** * @typedef {Object} Waker * @property {() => void} wake diff --git a/packages/zoe/test/unitTests/contractSupport/test-offerTo.js b/packages/zoe/test/unitTests/contractSupport/test-offerTo.js index 84cf17b8c6e..f1b5da9df29 100644 --- a/packages/zoe/test/unitTests/contractSupport/test-offerTo.js +++ b/packages/zoe/test/unitTests/contractSupport/test-offerTo.js @@ -233,8 +233,7 @@ test(`offerTo - violates offer safety of fromSeat`, async t => { toSeatContractA, ), { - message: - 'The trade between left [object Object] and right [object Object] failed offer safety. Please check the log for more information', + message: /The trade between left .* and right .* failed offer safety. Please check the log for more information/, }, ); diff --git a/packages/zoe/test/unitTests/contractSupport/test-withdrawFrom.js b/packages/zoe/test/unitTests/contractSupport/test-withdrawFrom.js index 4a627bd38d0..247120bff61 100644 --- a/packages/zoe/test/unitTests/contractSupport/test-withdrawFrom.js +++ b/packages/zoe/test/unitTests/contractSupport/test-withdrawFrom.js @@ -88,8 +88,7 @@ test(`withdrawFromSeat - violates offerSafety`, async t => { await t.throwsAsync( withdrawFromSeat(zcf, zcfSeat, { B: bucks(4) }), { - message: - 'The trade between left [object Object] and right [object Object] failed offer safety. Please check the log for more information', + message: /The trade between left .* and right .* failed offer safety. Please check the log for more information/, }, `withdrawFrom can't violate offerSafety`, ); diff --git a/packages/zoe/test/unitTests/contracts/test-coveredCall.js b/packages/zoe/test/unitTests/contracts/test-coveredCall.js index 036f1ddf6b3..a6294985436 100644 --- a/packages/zoe/test/unitTests/contracts/test-coveredCall.js +++ b/packages/zoe/test/unitTests/contracts/test-coveredCall.js @@ -5,7 +5,7 @@ import test from 'ava'; import bundleSource from '@agoric/bundle-source'; import { E } from '@agoric/eventual-send'; -import { sameStructure } from '@agoric/same-structure'; +import { sameStructure, matches, STAR_PATTERN } from '@agoric/same-structure'; import { makeLocalAmountMath } from '@agoric/ertp'; import buildManualTimer from '../../../tools/manualTimer'; @@ -337,7 +337,6 @@ test(`zoe - coveredCall - alice's deadline expires, cancelling alice and bob`, a // trick Dave? Can Dave describe what it is that he wants in the swap // offer description? test('zoe - coveredCall with swap for invitation', async t => { - t.plan(24); // Setup the environment const timer = buildManualTimer(console.log); const { moolaR, simoleanR, bucksR, moola, simoleans, bucks, zoe } = setup(); @@ -426,6 +425,23 @@ test('zoe - coveredCall with swap for invitation', async t => { t.is(optionDesc.expirationDate, 100); t.deepEqual(optionDesc.timeAuthority, timer); + const optionDescPattern = harden({ + handle: STAR_PATTERN, + instance: STAR_PATTERN, + installation: coveredCallInstallation, + description: 'exerciseOption', + underlyingAssets: { UnderlyingAsset: moola(3) }, + strikePrice: { StrikePrice: simoleans(7) }, + expirationDate: 100, + timeAuthority: timer, + }); + t.assert(matches(optionDescPattern, optionDesc)); + + const optionAmountPattern = invitationAmountMath.makePattern( + harden([optionDescPattern]), + ); + t.assert(invitationAmountMath.satisfies(optionAmountPattern, optionAmount)); + // Let's imagine that Bob wants to create a swap to trade this // invitation for bucks. const swapIssuerKeywordRecord = harden({ @@ -491,7 +507,14 @@ test('zoe - coveredCall with swap for invitation', async t => { // Dave escrows his 1 buck with Zoe and forms his proposal const daveSwapProposal = harden({ - want: { Asset: optionAmount }, + // We used to say + // ```js + // want: { Asset: optionAmount }, + // ``` + // which is the ground case. Instead we now test with optionAmountPattern + // which is a non-ground pattern, much easier for Dave (aka Fred) to + // express. + want: { Asset: optionAmountPattern }, give: { Price: bucks(1) }, }); diff --git a/packages/zoe/test/unitTests/contracts/test-multipoolAutoswap.js b/packages/zoe/test/unitTests/contracts/test-multipoolAutoswap.js index c6c73fccd6f..9f7362dff44 100644 --- a/packages/zoe/test/unitTests/contracts/test-multipoolAutoswap.js +++ b/packages/zoe/test/unitTests/contracts/test-multipoolAutoswap.js @@ -1193,8 +1193,7 @@ test('multipoolAutoSwap jig - removeLiquidity ask for too much', async t => { payment, ); await t.throwsAsync(() => seat.getOfferResult(), { - message: - 'The trade between left [object Object] and right [object Object] failed offer safety. Please check the log for more information', + message: /The trade between left .* and right .* failed offer safety. Please check the log for more information/, }); }); diff --git a/packages/zoe/test/unitTests/zcf/test-zoeHelpersWZcf.js b/packages/zoe/test/unitTests/zcf/test-zoeHelpersWZcf.js index 1c17ee42b72..f5881a980b2 100644 --- a/packages/zoe/test/unitTests/zcf/test-zoeHelpersWZcf.js +++ b/packages/zoe/test/unitTests/zcf/test-zoeHelpersWZcf.js @@ -91,8 +91,7 @@ test(`zoeHelper with zcf - swap no match`, async t => { t.throws( () => swap(zcf, aZcfSeat, bZcfSeat), { - message: - 'The trade between left [object Object] and right [object Object] failed.', + message: /The trade between fromSeat .* and toSeat .* failed because .* was not found/, }, 'mismatched offers', );