From e7c122cc744d46892c28f7e912376ba3a0a5bcf0 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Tue, 13 Oct 2020 22:36:48 -0700 Subject: [PATCH] fix: Keep strSet sorted. Add bags --- packages/ERTP/src/amountMath.js | 11 +- .../ERTP/src/mathHelpers/strBagMathHelpers.js | 169 +++++++++++ .../ERTP/src/mathHelpers/strSetMathHelpers.js | 109 +++++-- .../mathHelpers/test-strBagMathHelpers.js | 283 ++++++++++++++++++ .../mathHelpers/test-strSetMathHelpers.js | 14 +- 5 files changed, 552 insertions(+), 34 deletions(-) create mode 100644 packages/ERTP/src/mathHelpers/strBagMathHelpers.js create mode 100644 packages/ERTP/test/unitTests/mathHelpers/test-strBagMathHelpers.js diff --git a/packages/ERTP/src/amountMath.js b/packages/ERTP/src/amountMath.js index fffabbe811d..906340f1e76 100644 --- a/packages/ERTP/src/amountMath.js +++ b/packages/ERTP/src/amountMath.js @@ -6,19 +6,21 @@ import { mustBeComparable } from '@agoric/same-structure'; import './types'; import natMathHelpers from './mathHelpers/natMathHelpers'; -import strSetMathHelpers from './mathHelpers/strSetMathHelpers'; import setMathHelpers from './mathHelpers/setMathHelpers'; +import strSetMathHelpers from './mathHelpers/strSetMathHelpers'; +import strBagMathHelpers from './mathHelpers/strBagMathHelpers'; // We want an enum, but narrowed to the AmountMathKind type. /** * Constants for the kinds of amountMath we support. * - * @type {{ NAT: 'nat', SET: 'set', STRING_SET: 'strSet' }} + * @type {{ NAT: 'nat', SET: 'set', STRING_SET: 'strSet' , STRING_BAG: 'strBag' }} */ const MathKind = { NAT: 'nat', SET: 'set', STRING_SET: 'strSet', + STRING_BAG: 'strBag', }; harden(MathKind); export { MathKind }; @@ -64,7 +66,7 @@ export { MathKind }; * makeLocalAmountMath(issuer). * * AmountMath exports MathKind, which contains constants for the kinds: - * NAT, SET, and STRING_SET. + * NAT, SET, STRING_SET, and STRING_BAG. * * Each issuer of digital assets has an associated brand in a one-to-one * mapping. In untrusted contexts, such as in analyzing payments and @@ -81,8 +83,9 @@ function makeAmountMath(brand, amountMathKind) { const mathHelpers = { nat: natMathHelpers, - strSet: strSetMathHelpers, set: setMathHelpers, + strSet: strSetMathHelpers, + strBag: strBagMathHelpers, }; const helpers = mathHelpers[amountMathKind]; assert( diff --git a/packages/ERTP/src/mathHelpers/strBagMathHelpers.js b/packages/ERTP/src/mathHelpers/strBagMathHelpers.js new file mode 100644 index 00000000000..1a1dfd525b2 --- /dev/null +++ b/packages/ERTP/src/mathHelpers/strBagMathHelpers.js @@ -0,0 +1,169 @@ +// @ts-check + +import Nat from '@agoric/nat'; +import { passStyleOf } from '@agoric/marshal'; +import { assert, details } from '@agoric/assert'; + +const identity = harden({ strings: [], counts: [] }); + +const assertUniqueSorted = strings => { + const len = strings.length; + for (let i = 1; i < len; i += 1) { + const leftStr = strings[i - 1]; + const rightStr = strings[i]; + assert(leftStr !== rightStr, details`value has duplicates: ${strings}`); + assert(leftStr < rightStr, details`value not sorted ${strings}`); + } +}; + +/** + * Operations for arrays with unique string elements. More information + * about these assets might be provided by some other mechanism, such as + * an off-chain API or physical records. strBagMathHelpers are highly + * efficient, but if the users rely on an external resource to learn + * more about the digital assets (for example, looking up a string ID + * in a database), the administrator of the external resource could + * potentially change the external definition at any time. + * + * @type {MathHelpers} + */ +const strBagMathHelpers = harden({ + doCoerce: bag => { + const { strings, counts } = bag; + assert(passStyleOf(strings) === 'copyArray', 'value must be an array'); + assert(passStyleOf(counts) === 'copyArray', 'value must be an array'); + assert.equal(strings.length, counts.length); + strings.forEach(str => assert.typeof(str, 'string')); + counts.forEach(count => Nat(count) && assert(count >= 1)); + assertUniqueSorted(strings); + return harden(bag); + }, + doGetEmpty: _ => identity, + doIsEmpty: ({ strings }) => + passStyleOf(strings) === 'copyArray' && strings.length === 0, + doIsGTE: (leftBag, rightBag) => { + const { strings: leftStrings, counts: leftCounts } = leftBag; + const { strings: rightStrings, counts: rightCounts } = rightBag; + let leftI = 0; + let rightI = 0; + const leftLen = leftStrings.length; + const rightLen = rightStrings.length; + while (leftI < leftLen && rightI < rightLen) { + const leftStr = leftStrings[leftI]; + const rightStr = rightStrings[rightI]; + if (leftStr < rightStr) { + // elements of left not in right. Fine + leftI += 1; + } else if (leftStr > rightStr) { + // elements of right not in left. + return false; + } else if (leftCounts[leftI] < rightCounts[rightI]) { + // more of this element on right than in left. + return false; + } else { + leftI += 1; + rightI += 1; + } + } + // Are there no elements of right remaining? + return rightI >= rightLen; + }, + doIsEqual: (leftBag, rightBag) => { + const { strings: leftStrings, counts: leftCounts } = leftBag; + const { strings: rightStrings, counts: rightCounts } = rightBag; + if (leftStrings.length !== rightStrings.length) { + return false; + } + return leftStrings.every( + (leftStr, i) => + leftStr === rightStrings[i] && leftCounts[i] === rightCounts[i], + ); + }, + doAdd: (leftBag, rightBag) => { + const { strings: leftStrings, counts: leftCounts } = leftBag; + const { strings: rightStrings, counts: rightCounts } = rightBag; + const resultStrings = []; + const resultCounts = []; + let leftI = 0; + let rightI = 0; + const leftLen = leftStrings.length; + const rightLen = rightStrings.length; + while (leftI < leftLen && rightI < rightLen) { + const leftStr = leftStrings[leftI]; + const rightStr = rightStrings[rightI]; + if (leftStr < rightStr) { + resultStrings.push(leftStr); + resultCounts.push(leftCounts[leftI]); + leftI += 1; + } else if (leftStr > rightStr) { + resultStrings.push(rightStr); + resultCounts.push(rightCounts[rightI]); + rightI += 1; + } else { + resultStrings.push(leftStr); + resultCounts.push(Nat(leftCounts[leftI] + rightCounts[rightI])); + leftI += 1; + rightI += 1; + } + } + if (leftI < leftLen) { + resultStrings.push(leftStrings[leftI]); + resultCounts.push(leftCounts[leftI]); + } else if (rightI < rightLen) { + resultStrings.push(rightStrings[rightI]); + resultCounts.push(rightCounts[rightI]); + } + return harden({ strings: resultStrings, counts: resultCounts }); + }, + doSubtract: (leftBag, rightBag) => { + const { strings: leftStrings, counts: leftCounts } = leftBag; + const { strings: rightStrings, counts: rightCounts } = rightBag; + const resultStrings = []; + const resultCounts = []; + let leftI = 0; + let rightI = 0; + const leftLen = leftStrings.length; + const rightLen = rightStrings.length; + while (leftI < leftLen && rightI < rightLen) { + const leftStr = leftStrings[leftI]; + const rightStr = rightStrings[rightI]; + assert( + leftStr <= rightStr, + details`element of right not present in left ${rightStr}`, + ); + if (leftStr < rightStr) { + // an element of left not in right. Keep. + resultStrings.push(leftStr); + resultCounts.push(leftCounts[leftI]); + leftI += 1; + } else { + // element in both. + const leftCount = leftCounts[leftI]; + const rightCount = rightCounts[rightI]; + assert( + leftCount >= rightCount, + details`more of element on right than left ${rightStr},${rightCount}`, + ); + const resultCount = Nat(leftCount - rightCount); + if (resultCount >= 1) { + resultStrings.push(leftStr); + resultCounts.push(resultCount); + } + leftI += 1; + rightI += 1; + } + } + assert( + rightI >= rightLen, + details`some of the elements in right (${rightStrings}) were not present in left (${leftStrings})`, + ); + if (leftI < leftLen) { + resultStrings.push(leftStrings[leftI]); + resultCounts.push(leftCounts[leftI]); + } + return harden({ strings: resultStrings, counts: resultCounts }); + }, +}); + +harden(strBagMathHelpers); +export default strBagMathHelpers; diff --git a/packages/ERTP/src/mathHelpers/strSetMathHelpers.js b/packages/ERTP/src/mathHelpers/strSetMathHelpers.js index 812f24b1adf..a51eaf2efb5 100644 --- a/packages/ERTP/src/mathHelpers/strSetMathHelpers.js +++ b/packages/ERTP/src/mathHelpers/strSetMathHelpers.js @@ -5,9 +5,14 @@ import { assert, details } from '@agoric/assert'; const identity = harden([]); -const checkForDupes = list => { - const set = new Set(list); - assert(set.size === list.length, details`value has duplicates: ${list}`); +const assertUniqueSorted = list => { + const len = list.length; + for (let i = 1; i < len; i += 1) { + const leftStr = list[i - 1]; + const rightStr = list[i]; + assert(leftStr !== rightStr, details`value has duplicates: ${list}`); + assert(leftStr < rightStr, details`value not sorted ${list}`); + } }; /** @@ -25,42 +30,98 @@ const strSetMathHelpers = harden({ doCoerce: list => { assert(passStyleOf(list) === 'copyArray', 'value must be an array'); list.forEach(elem => assert.typeof(elem, 'string')); - checkForDupes(list); - return list; + assertUniqueSorted(list); + return harden(list); }, doGetEmpty: _ => identity, doIsEmpty: list => passStyleOf(list) === 'copyArray' && list.length === 0, doIsGTE: (left, right) => { - const leftSet = new Set(left); - const leftHas = elem => leftSet.has(elem); - return right.every(leftHas); + let leftI = 0; + let rightI = 0; + const leftLen = left.length; + const rightLen = right.length; + while (leftI < leftLen && rightI < rightLen) { + const leftStr = left[leftI]; + const rightStr = right[rightI]; + if (leftStr < rightStr) { + // an element of left not in right. Fine + leftI += 1; + } else if (leftStr > rightStr) { + // an element of right not in left. + return false; + } else { + leftI += 1; + rightI += 1; + } + } + // Are there no elements of right remaining? + return rightI >= rightLen; }, doIsEqual: (left, right) => { - const leftSet = new Set(left); - const leftHas = elem => leftSet.has(elem); - return left.length === right.length && right.every(leftHas); + if (left.length !== right.length) { + return false; + } + return left.every((leftStr, i) => leftStr === right[i]); }, doAdd: (left, right) => { - const union = new Set(left); - const addToUnion = elem => { + const result = []; + let leftI = 0; + let rightI = 0; + const leftLen = left.length; + const rightLen = right.length; + while (leftI < leftLen && rightI < rightLen) { + const leftStr = left[leftI]; + const rightStr = right[rightI]; assert( - !union.has(elem), - details`left and right have same element ${elem}`, + leftStr !== rightStr, + details`left and right have same element ${leftStr}`, ); - union.add(elem); - }; - right.forEach(addToUnion); - return harden(Array.from(union)); + if (leftStr < rightStr) { + result.push(leftStr); + leftI += 1; + } else { + result.push(rightStr); + rightI += 1; + } + } + if (leftI < leftLen) { + result.push(left[leftI]); + } else if (rightI < rightLen) { + result.push(right[rightI]); + } + return harden(result); }, doSubtract: (left, right) => { - const leftSet = new Set(left); - const remove = elem => leftSet.delete(elem); - const allRemovedCorrectly = right.every(remove); + const result = []; + let leftI = 0; + let rightI = 0; + const leftLen = left.length; + const rightLen = right.length; + while (leftI < leftLen && rightI < rightLen) { + const leftStr = left[leftI]; + const rightStr = right[rightI]; + assert( + leftStr <= rightStr, + details`element of right not present in left ${rightStr}`, + ); + if (leftStr < rightStr) { + // an element of left not in right. Keep. + result.push(leftStr); + leftI += 1; + } else { + // element in both. Skip. + leftI += 1; + rightI += 1; + } + } assert( - allRemovedCorrectly, + rightI >= rightLen, details`some of the elements in right (${right}) were not present in left (${left})`, ); - return harden(Array.from(leftSet)); + if (leftI < leftLen) { + result.push(left[leftI]); + } + return harden(result); }, }); diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-strBagMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-strBagMathHelpers.js new file mode 100644 index 00000000000..46f6726ee16 --- /dev/null +++ b/packages/ERTP/test/unitTests/mathHelpers/test-strBagMathHelpers.js @@ -0,0 +1,283 @@ +import test from 'ava'; + +import { makeAmountMath, MathKind } from '../../../src'; + +// The "unit tests" for MathHelpers actually make the calls through +// AmountMath so that we can test that any duplication is handled +// correctly. + +const bagit = strings => harden({ strings, counts: strings.map(_ => 1) }); + +const mockBrand = harden({ + isMyIssuer: () => false, + getAllegedName: () => 'mock', +}); + +const amountMath = makeAmountMath(mockBrand, 'strBag'); + +test('strSetMathHelpers', t => { + const { + getBrand, + getAmountMathKind, + make, + coerce, + getValue, + getEmpty, + isEmpty, + isGTE, + isEqual, + add, + subtract, + } = amountMath; + + // getBrand + t.deepEqual(getBrand(), mockBrand, 'brand is brand'); + + // getAmountMathKind + t.deepEqual( + getAmountMathKind(), + MathKind.STRING_BAG, + 'amountMathKind is strBag', + ); + + // make + t.notThrows( + () => make(harden(bagit(['1']))), + `['1'] is a valid string array`, + ); + t.throws( + () => make(4), + { message: /value must be an array/ }, + `4 is not a valid string array`, + ); + t.throws( + () => make(harden(bagit([6]))), + { message: /must be a string/ }, + `[6] is not a valid string array`, + ); + t.throws( + () => make('abc'), + { message: /value must be an array/ }, + `'abc' is not a valid string array`, + ); + t.throws( + () => make(harden(bagit(['a', 'a']))), + { message: /value has duplicates/ }, + `duplicates in make throw`, + ); + + // coerce + t.deepEqual( + coerce(harden({ brand: mockBrand, value: bagit(['1']) })), + harden({ brand: mockBrand, value: bagit(['1']) }), + `coerce({ brand, value: bagit(['1'])}) is a valid amount`, + ); + t.throws( + () => coerce(harden({ brand: mockBrand, value: bagit([6]) })), + { message: /must be a string/ }, + `[6] is not a valid string array`, + ); + t.throws( + () => coerce(harden({ brand: mockBrand, value: '6' })), + { message: /value must be an array/ }, + `'6' is not a valid array`, + ); + t.throws( + () => coerce(harden({ brand: mockBrand, value: bagit(['a', 'a']) })), + { message: /value has duplicates/ }, + `duplicates should throw`, + ); + + // getValue + t.deepEqual( + getValue(harden({ brand: mockBrand, value: bagit(['1']) })), + bagit(['1']), + ); + t.deepEqual(getValue(make(harden(bagit(['1'])))), bagit(['1'])); + + // getEmpty + t.deepEqual( + getEmpty(), + harden({ brand: mockBrand, value: bagit([]) }), + `empty is { strings: [], counts: [] }`, + ); + + t.assert( + isEmpty(harden({ brand: mockBrand, value: bagit([]) })), + `isEmpty({ strings: [], counts: [] }) is true`, + ); + t.falsy( + isEmpty(harden({ brand: mockBrand, value: bagit(['abc']) })), + `isEmpty(bagit(['abc'])) is false`, + ); + t.throws( + () => isEmpty(harden({ brand: mockBrand, value: bagit(['a', 'a']) })), + { message: /value has duplicates/ }, + `duplicates in isEmpty throw because coerce throws`, + ); + + // isGTE + t.throws( + () => + isGTE( + harden({ brand: mockBrand, value: bagit(['a', 'a']) }), + harden({ brand: mockBrand, value: bagit(['b']) }), + ), + null, + `duplicates in the left of isGTE should throw`, + ); + t.throws( + () => + isGTE( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['b', 'b']) }), + ), + null, + `duplicates in the right of isGTE should throw`, + ); + t.assert( + isGTE( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + `overlap between left and right of isGTE should not throw`, + ); + t.assert( + isGTE( + harden({ brand: mockBrand, value: bagit(['a', 'b']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + `bagit(['a', 'b']) is gte to bagit(['a'])`, + ); + t.falsy( + isGTE( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['b']) }), + ), + `bagit(['a']) is not gte to bagit(['b'])`, + ); + + // isEqual + t.throws( + () => + isEqual( + harden({ brand: mockBrand, value: bagit(['a', 'a']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + { message: /value has duplicates/ }, + `duplicates in left of isEqual should throw`, + ); + t.throws( + () => + isEqual( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['a', 'a']) }), + ), + { message: /value has duplicates/ }, + `duplicates in right of isEqual should throw`, + ); + t.assert( + isEqual( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + `overlap between left and right of isEqual is ok`, + ); + t.throws( + () => + isEqual( + harden({ brand: mockBrand, value: bagit(['a', 'b']) }), + harden({ brand: mockBrand, value: bagit(['b', 'a']) }), + ), + { message: /value not sorted/ }, + `unsorted list should throw`, + ); + t.falsy( + isEqual( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['b']) }), + ), + `bagit(['a']) does not equal bagit(['b'])`, + ); + + // add + t.throws( + () => + add( + harden({ brand: mockBrand, value: bagit(['a', 'a']) }), + harden({ brand: mockBrand, value: bagit(['b']) }), + ), + { message: /value has duplicates/ }, + `duplicates in left of add should throw`, + ); + t.throws( + () => + add( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['b', 'b']) }), + ), + { message: /value has duplicates/ }, + `duplicates in right of add should throw`, + ); + t.deepEqual( + add( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + harden({ brand: mockBrand, value: { strings: ['a'], counts: [2] } }), + `bagit(['a']) + bagit(['a']) = { strings: ['a'], counts: [2] }`, + ); + t.deepEqual( + add( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['b']) }), + ), + harden({ brand: mockBrand, value: bagit(['a', 'b']) }), + `bagit(['a']) + bagit(['b']) = bagit(['a', 'b'])`, + ); + + // subtract + t.throws( + () => + subtract( + harden({ brand: mockBrand, value: bagit(['a', 'a']) }), + harden({ brand: mockBrand, value: bagit(['b']) }), + ), + { message: /value has duplicates/ }, + `duplicates in left of subtract should throw`, + ); + t.throws( + () => + subtract( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['b', 'b']) }), + ), + { message: /value has duplicates/ }, + `duplicates in right of subtract should throw`, + ); + t.deepEqual( + subtract( + harden({ brand: mockBrand, value: bagit(['a']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + harden({ brand: mockBrand, value: bagit([]) }), + `overlap between left and right of subtract should not throw`, + ); + t.throws( + () => + subtract( + harden({ brand: mockBrand, value: bagit(['a', 'b']) }), + harden({ brand: mockBrand, value: bagit(['c']) }), + ), + { message: /some of the elements in right .* were not present in left/ }, + `elements in right but not in left of subtract should throw`, + ); + t.deepEqual( + subtract( + harden({ brand: mockBrand, value: bagit(['a', 'b']) }), + harden({ brand: mockBrand, value: bagit(['a']) }), + ), + harden({ brand: mockBrand, value: bagit(['b']) }), + `bagit(['a', 'b']) - bagit(['a']) = bagit(['a'])`, + ); +}); diff --git a/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js b/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js index 9737997080c..f376687e991 100644 --- a/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js +++ b/packages/ERTP/test/unitTests/mathHelpers/test-strSetMathHelpers.js @@ -175,12 +175,14 @@ test('strSetMathHelpers', t => { ), `overlap between left and right of isEqual is ok`, ); - t.assert( - isEqual( - harden({ brand: mockBrand, value: ['a', 'b'] }), - harden({ brand: mockBrand, value: ['b', 'a'] }), - ), - `['a', 'b'] equals ['b', 'a']`, + t.throws( + () => + isEqual( + harden({ brand: mockBrand, value: ['a', 'b'] }), + harden({ brand: mockBrand, value: ['b', 'a'] }), + ), + { message: /value not sorted/ }, + `unsorted list should throw`, ); t.falsy( isEqual(