Skip to content

Commit

Permalink
feat: allow sparse keywords (#812)
Browse files Browse the repository at this point in the history
Zoe currently has the concept of keywords which are used as indexes in place of the array indexes that we had previously. The keywords are used to match proposal elements, payments, amounts in the offerRecord within Zoe, and payouts to the user. Keywords are per contract, and are currently objective: they are the same for all users of a contract.

We expect that future contracts (specifically multipool Autoswap #391 ) will have many keywords, potentially hundreds. Therefore, we want to ensure that the keywordRecords (records using keywords as keys) throughout Zoe are sparse, much sparser than all the keywords for a contract.
  • Loading branch information
katelynsills authored Apr 4, 2020
1 parent 29b5399 commit dcc9ba3
Show file tree
Hide file tree
Showing 14 changed files with 457 additions and 325 deletions.
143 changes: 79 additions & 64 deletions packages/zoe/src/cleanProposal.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,89 +2,109 @@ import harden from '@agoric/harden';
import { assert, details } from '@agoric/assert';
import { mustBeComparable } from '@agoric/same-structure';

import { arrayToObj } from './objArrayConversion';
import { arrayToObj, assertSubset } from './objArrayConversion';

// cleanProposal checks the keys and values of the proposal, including
// the keys and values of the internal objects. The proposal may have
// the following keys: `give`, `want`, and `exit`. These keys may be
// omitted in the `proposal` argument passed to cleanProposal, but
// anything other than these keys is not allowed. The values of `give`
// and `want` must be "amountKeywordRecords", meaning that the keys
// must be keywords and the values must be amounts. The value of
// `exit`, if present, must be a record of one of the following forms:
// `{ waived: null }` `{ onDemand: null }` `{ afterDeadline: { timer
// :Timer, deadline :Number } }
export const assertCapASCII = keyword => {
assert.typeof(keyword, 'string');
const firstCapASCII = /^[A-Z][a-zA-Z0-9_$]*$/;
assert(
firstCapASCII.test(keyword),
details`keyword ${keyword} must be ascii and must start with a capital letter.`,
);
};

// Assert that the keys of record, if present, are in expectedKeys.
// Return the keys after asserting this.
const checkKeys = (expectedKeys, record) => {
// Assert that keys, if present, match expectedKeys.
// Assert that the keys of `record` are all in `allowedKeys`. If a key
// of `record` is not in `allowedKeys`, throw an error. If a key in
// `allowedKeys` is not a key of record, we do not throw an error.
const assertKeysAllowed = (allowedKeys, record) => {
const keys = Object.getOwnPropertyNames(record);
keys.forEach(key => {
assert.typeof(key, 'string');
assert(
expectedKeys.includes(key),
details`key ${key} was not an expected key`,
);
});
assertSubset(allowedKeys, keys);
// assert that there are no symbol properties.
assert(
Object.getOwnPropertySymbols(record).length === 0,
details`no symbol properties allowed`,
);
return keys;
};

const coerceAmountKeywordRecordValues = (
const cleanKeys = (allowedKeys, record) => {
assertKeysAllowed(allowedKeys, record);
return Object.getOwnPropertyNames(record);
};

export const getKeywords = keywordRecord =>
harden(Object.getOwnPropertyNames(keywordRecord));

export const coerceAmountKeywordRecord = (
amountMathKeywordRecord,
validatedKeywords,
allKeywords,
allegedAmountKeywordRecord,
) => {
const sparseKeywords = cleanKeys(allKeywords, allegedAmountKeywordRecord);
// Check that each value can be coerced using the amountMath indexed
// by keyword. `AmountMath.coerce` throws if coercion fails.
const coercedAmounts = validatedKeywords.map(keyword =>
const coercedAmounts = sparseKeywords.map(keyword =>
amountMathKeywordRecord[keyword].coerce(
allegedAmountKeywordRecord[keyword],
),
);

// Recreate the amountKeywordRecord with coercedAmounts.
return arrayToObj(coercedAmounts, validatedKeywords);
return arrayToObj(coercedAmounts, sparseKeywords);
};

export const coerceAmountKeywordRecord = (
amountMathKeywordRecord,
keywords,
allegedAmountKeywordRecord,
) => {
const validatedKeywords = checkKeys(keywords, allegedAmountKeywordRecord);
return coerceAmountKeywordRecordValues(
amountMathKeywordRecord,
validatedKeywords,
allegedAmountKeywordRecord,
export const cleanKeywords = keywordRecord => {
// `getOwnPropertyNames` returns all the non-symbol properties
// (both enumerable and non-enumerable).
const keywords = Object.getOwnPropertyNames(keywordRecord);

// Insist that there are no symbol properties.
assert(
Object.getOwnPropertySymbols(keywordRecord).length === 0,
details`no symbol properties allowed`,
);

// Assert all key characters are ascii and keys start with a
// capital letter.
keywords.forEach(assertCapASCII);

return keywords;
};

export const cleanProposal = (keywords, amountMathKeywordRecord, proposal) => {
const expectedRootKeys = ['want', 'give', 'exit'];
// cleanProposal checks the keys and values of the proposal, including
// the keys and values of the internal objects. The proposal may have
// the following keys: `give`, `want`, and `exit`. These keys may be
// omitted in the `proposal` argument passed to cleanProposal, but
// anything other than these keys is not allowed. The values of `give`
// and `want` must be "amountKeywordRecords", meaning that the keys
// must be keywords and the values must be amounts. The value of
// `exit`, if present, must be a record of one of the following forms:
// `{ waived: null }` `{ onDemand: null }` `{ afterDeadline: { timer
// :Timer, deadline :Number } }
export const cleanProposal = (
issuerKeywordRecord,
amountMathKeywordRecord,
proposal,
) => {
const rootKeysAllowed = ['want', 'give', 'exit'];
mustBeComparable(proposal);
checkKeys(expectedRootKeys, proposal);
assertKeysAllowed(rootKeysAllowed, proposal);

// We fill in the default values if the keys are undefined.
let { want = harden({}), give = harden({}) } = proposal;
const { exit = harden({ onDemand: null }) } = proposal;

want = coerceAmountKeywordRecord(amountMathKeywordRecord, keywords, want);
give = coerceAmountKeywordRecord(amountMathKeywordRecord, keywords, give);
const allKeywords = getKeywords(issuerKeywordRecord);
want = coerceAmountKeywordRecord(amountMathKeywordRecord, allKeywords, want);
give = coerceAmountKeywordRecord(amountMathKeywordRecord, allKeywords, give);

// Check exit
assert(
Object.getOwnPropertyNames(exit).length === 1,
details`exit ${proposal.exit} should only have one key`,
);
// We expect the single exit key to be one of the following:
const expectedExitKeys = ['onDemand', 'afterDeadline', 'waived'];
const [exitKey] = checkKeys(expectedExitKeys, exit);
const allowedExitKeys = ['onDemand', 'afterDeadline', 'waived'];
const [exitKey] = cleanKeys(allowedExitKeys, exit);
if (exitKey === 'onDemand' || exitKey === 'waived') {
assert(
exit[exitKey] === null,
Expand All @@ -93,37 +113,32 @@ export const cleanProposal = (keywords, amountMathKeywordRecord, proposal) => {
}
if (exitKey === 'afterDeadline') {
const expectedAfterDeadlineKeys = ['timer', 'deadline'];
checkKeys(expectedAfterDeadlineKeys, exit.afterDeadline);
assertKeysAllowed(expectedAfterDeadlineKeys, exit.afterDeadline);
assert(
exit.afterDeadline.timer !== undefined,
details`timer must be defined`,
);
assert(
exit.afterDeadline.deadline !== undefined,
details`deadline must be defined`,
);
// timers must have a 'setWakeup' function which takes a deadline
// and an object as arguments.
// TODO: document timer interface
// https://github.com/Agoric/agoric-sdk/issues/751
// TODO: how to check methods on presences?
}

const hasPropDefined = (obj, prop) => obj[prop] !== undefined;
// check that keyword is not in both 'want' and 'give'.
const wantKeywordSet = new Set(Object.getOwnPropertyNames(want));
const giveKeywords = Object.getOwnPropertyNames(give);

// Create an unfrozen version of 'want' in case we need to add
// properties.
const wantObj = { ...want };

keywords.forEach(keyword => {
// check that keyword is not in both 'want' and 'give'.
const wantHas = hasPropDefined(wantObj, keyword);
const giveHas = hasPropDefined(give, keyword);
giveKeywords.forEach(keyword => {
assert(
!(wantHas && giveHas),
!wantKeywordSet.has(keyword),
details`a keyword cannot be in both 'want' and 'give'`,
);
// If keyword is in neither, fill in with a 'want' of empty.
if (!(wantHas || giveHas)) {
wantObj[keyword] = amountMathKeywordRecord[keyword].getEmpty();
}
});

return harden({
want: wantObj,
give,
exit,
});
return harden({ want, give, exit });
};
57 changes: 29 additions & 28 deletions packages/zoe/src/contracts/autoswap.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export const makeContract = harden(zoe => {
let liqTokenSupply = 0;

return zoe.addNewIssuer(liquidityIssuer, 'Liquidity').then(() => {
const { issuerKeywordRecord } = zoe.getInstanceRecord();
const amountMaths = zoe.getAmountMaths(issuerKeywordRecord);
const amountMaths = zoe.getAmountMaths(
harden(['TokenA', 'TokenB', 'Liquidity']),
);
Object.values(amountMaths).forEach(amountMath =>
assert(
amountMath.getMathHelpersName() === 'nat',
Expand All @@ -39,19 +40,19 @@ export const makeContract = harden(zoe => {
} = makeConstProductBC(zoe);

return makeEmptyOffer().then(poolHandle => {
const getPoolAmounts = () => zoe.getOffer(poolHandle).amounts;
const getPoolAllocation = () => zoe.getCurrentAllocation(poolHandle);

const makeInvite = () => {
const seat = harden({
swap: () => {
const { proposal } = zoe.getOffer(inviteHandle);
const giveTokenA = harden({
give: ['TokenA'],
want: ['TokenB', 'Liquidity'],
want: ['TokenB'],
});
const giveTokenB = harden({
give: ['TokenB'],
want: ['TokenA', 'Liquidity'],
want: ['TokenA'],
});
let giveKeyword;
let wantKeyword;
Expand All @@ -64,23 +65,23 @@ export const makeContract = harden(zoe => {
} else {
return rejectOffer(inviteHandle);
}
if (!amountMaths.Liquidity.isEmpty(proposal.want.Liquidity)) {
if (proposal.want.Liquidity !== undefined) {
rejectOffer(
inviteHandle,
`A Liquidity amount should not be present in a swap`,
);
}

const poolAmounts = getPoolAmounts();
const poolAllocation = getPoolAllocation();
const {
outputExtent,
newInputReserve,
newOutputReserve,
} = getPrice(
harden({
inputExtent: proposal.give[giveKeyword].extent,
inputReserve: poolAmounts[giveKeyword].extent,
outputReserve: poolAmounts[wantKeyword].extent,
inputReserve: poolAllocation[giveKeyword].extent,
outputReserve: poolAllocation[wantKeyword].extent,
}),
);
const amountOut = amountMaths[wantKeyword].make(outputExtent);
Expand All @@ -97,7 +98,7 @@ export const makeContract = harden(zoe => {
newUserAmounts[giveKeyword] = amountMaths[giveKeyword].getEmpty();
newUserAmounts[wantKeyword] = amountOut;

const newPoolAmounts = { Liquidity: poolAmounts.Liquidity };
const newPoolAmounts = { Liquidity: poolAllocation.Liquidity };
newPoolAmounts[giveKeyword] = amountMaths[giveKeyword].make(
newInputReserve,
);
Expand All @@ -119,8 +120,8 @@ export const makeContract = harden(zoe => {
});
rejectIfNotProposal(inviteHandle, expected);

const userAmounts = zoe.getOffer(inviteHandle).amounts;
const poolAmounts = getPoolAmounts();
const userAllocation = zoe.getCurrentAllocation(inviteHandle);
const poolAllocation = getPoolAllocation();

// Calculate how many liquidity tokens we should be minting.
// Calculations are based on the extents represented by TokenA.
Expand All @@ -130,8 +131,8 @@ export const makeContract = harden(zoe => {
const liquidityExtentOut = calcLiqExtentToMint(
harden({
liqTokenSupply,
inputExtent: userAmounts.TokenA.extent,
inputReserve: poolAmounts.TokenA.extent,
inputExtent: userAllocation.TokenA.extent,
inputReserve: poolAllocation.TokenA.extent,
}),
);

Expand Down Expand Up @@ -160,9 +161,9 @@ export const makeContract = harden(zoe => {
amountMaths[key].add(obj1[key], obj2[key]);

const newPoolAmounts = harden({
TokenA: add('TokenA', userAmounts, poolAmounts),
TokenB: add('TokenB', userAmounts, poolAmounts),
Liquidity: poolAmounts.Liquidity,
TokenA: add('TokenA', userAllocation, poolAllocation),
TokenB: add('TokenB', userAllocation, poolAllocation),
Liquidity: poolAllocation.Liquidity,
});

const newUserAmounts = harden({
Expand Down Expand Up @@ -192,30 +193,30 @@ export const makeContract = harden(zoe => {
});
rejectIfNotProposal(inviteHandle, expected);

const userAmounts = zoe.getOffer(inviteHandle).amounts;
const liquidityExtentIn = userAmounts.Liquidity.extent;
const userAllocation = zoe.getCurrentAllocation(inviteHandle);
const liquidityExtentIn = userAllocation.Liquidity.extent;

const poolAmounts = getPoolAmounts();
const poolAllocation = getPoolAllocation();

const newUserAmounts = calcAmountsToRemove(
harden({
liqTokenSupply,
poolAmounts,
poolAllocation,
liquidityExtentIn,
}),
);

const newPoolAmounts = harden({
TokenA: amountMaths.TokenA.subtract(
poolAmounts.TokenA,
poolAllocation.TokenA,
newUserAmounts.TokenA,
),
TokenB: amountMaths.TokenB.subtract(
poolAmounts.TokenB,
poolAllocation.TokenB,
newUserAmounts.TokenB,
),
Liquidity: amountMaths.Liquidity.add(
poolAmounts.Liquidity,
poolAllocation.Liquidity,
amountMaths.Liquidity.make(liquidityExtentIn),
),
});
Expand Down Expand Up @@ -259,10 +260,10 @@ export const makeContract = harden(zoe => {
const inputExtent = amountMaths[inKeyword].getExtent(
amountInObj[inKeyword],
);
const poolAmounts = getPoolAmounts();
const inputReserve = poolAmounts[inKeyword].extent;
const poolAllocation = getPoolAllocation();
const inputReserve = poolAllocation[inKeyword].extent;
const outKeyword = inKeyword === 'TokenA' ? 'TokenB' : 'TokenA';
const outputReserve = poolAmounts[outKeyword].extent;
const outputReserve = poolAllocation[outKeyword].extent;
const { outputExtent } = getPrice(
harden({
inputExtent,
Expand All @@ -273,7 +274,7 @@ export const makeContract = harden(zoe => {
return amountMaths[outKeyword].make(outputExtent);
},
getLiquidityIssuer: () => liquidityIssuer,
getPoolAmounts,
getPoolAllocation,
makeInvite,
},
});
Expand Down
7 changes: 3 additions & 4 deletions packages/zoe/src/contracts/helpers/auctions.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,17 @@ export const closeAuction = (
zoe,
{ auctionLogicFn, sellerInviteHandle, allBidHandles },
) => {
const { issuerKeywordRecord } = zoe.getInstanceRecord();
const { Bid: bidAmountMath, Asset: assetAmountMath } = zoe.getAmountMaths(
issuerKeywordRecord,
harden(['Bid', 'Asset']),
);

// Filter out any inactive bids
const { active: activeBidHandles } = zoe.getOfferStatuses(
harden(allBidHandles),
);

const getBids = offerRecord => offerRecord.amounts.Bid;
const bids = zoe.getOffers(activeBidHandles).map(getBids);
const getBids = amountsKeywordRecord => amountsKeywordRecord.Bid;
const bids = zoe.getCurrentAllocations(activeBidHandles).map(getBids);
const assetAmount = zoe.getOffer(sellerInviteHandle).proposal.give.Asset;

const {
Expand Down
Loading

0 comments on commit dcc9ba3

Please sign in to comment.