Skip to content

Commit

Permalink
fix: multiples, for making divisible offers
Browse files Browse the repository at this point in the history
  • Loading branch information
erights committed Jul 29, 2023
1 parent d31700e commit 532cdb5
Show file tree
Hide file tree
Showing 18 changed files with 386 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ test.serial('errors', async t => {
}),
{
error: undefined,
numWantsSatisfied: 1,
numWantsSatisfied: Infinity,
},
);
await eventLoopIteration();
Expand Down
16 changes: 11 additions & 5 deletions packages/vats/test/bootstrapTests/test-vaults-integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ test('adjust balances', async t => {
updated: 'offerStatus',
status: {
id: 'adjust',
numWantsSatisfied: 1,
numWantsSatisfied: Infinity,
},
});
});
Expand Down Expand Up @@ -193,7 +193,8 @@ test('close vault', async t => {
updated: 'offerStatus',
status: {
id: 'close-insufficient',
numWantsSatisfied: 1, // trivially true because proposal `want` was empty.
// XXX there were no wants. Zoe treats as Infinitely satisfied
numWantsSatisfied: Infinity,
error: `Error: ${message}`,
},
});
Expand All @@ -215,6 +216,7 @@ test('close vault', async t => {
result: 'your vault is closed, thank you for your business',
// funds are returned
payouts: likePayouts(giveCollateral, 0),
numWantsSatisfied: Infinity,
},
});
});
Expand Down Expand Up @@ -280,7 +282,7 @@ test('exit bid', async t => {
status: {
id: 'bid',
result: 'Your bid has been accepted', // it was accepted before being exited
numWantsSatisfied: 1, // trivially 1 because there were no "wants" in the proposal
numWantsSatisfied: Infinity, // trivially, because there were no "wants" in the proposal
payouts: {
// got back the give
Bid: { value: 100000n },
Expand Down Expand Up @@ -309,7 +311,9 @@ test('propose change to auction governance param', async t => {
});

await eventLoopIteration();
t.like(wd.getLatestUpdateRecord(), { status: { numWantsSatisfied: 1 } });
t.like(wd.getLatestUpdateRecord(), {
status: { numWantsSatisfied: Infinity },
});

const auctioneer = agoricNamesRemotes.instance.auctioneer;
const timerBrand = agoricNamesRemotes.brand.timer;
Expand Down Expand Up @@ -341,7 +345,9 @@ test('propose change to auction governance param', async t => {
});

await eventLoopIteration();
t.like(wd.getLatestUpdateRecord(), { status: { numWantsSatisfied: 1 } });
t.like(wd.getLatestUpdateRecord(), {
status: { numWantsSatisfied: Infinity },
});

const { fromCapData } = makeMarshal(undefined, slotToBoardRemote);
const key = `published.committees.Economic_Committee.latestQuestion`;
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/api/test/test-lib-wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,7 @@ test('addOffer invitationQuery', async t => {
value: 1n,
},
},
multiples: 1n,
exit: {
onDemand: null,
},
Expand Down Expand Up @@ -1459,6 +1460,7 @@ test('addOffer offer.invitation', async t => {
value: 1n,
},
},
multiples: 1n,
exit: {
onDemand: null,
},
Expand Down
29 changes: 29 additions & 0 deletions packages/zoe/src/cleanProposal.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { assertRecord } from '@endo/marshal';
import { assertKey, assertPattern, mustMatch, isKey } from '@agoric/store';
import { FullProposalShape } from './typeGuards.js';
import { arrayToObj } from './objArrayConversion.js';
import { natSafeMath } from './contractSupport/safeMath.js';

import './internal-types.js';

const { values } = Object;
const { ownKeys } = Reflect;

export const MAX_KEYWORD_LENGTH = 100;
Expand Down Expand Up @@ -140,6 +142,7 @@ export const cleanProposal = (proposal, getAssetKindByBrand) => {
const {
want = harden({}),
give = harden({}),
multiples = 1n,
exit = harden({ onDemand: null }),
...rest
} = proposal;
Expand All @@ -155,10 +158,36 @@ export const cleanProposal = (proposal, getAssetKindByBrand) => {
const cleanedProposal = harden({
want: cleanedWant,
give: cleanedGive,
multiples,
exit,
});
mustMatch(cleanedProposal, FullProposalShape, 'proposal');
if (multiples > 1n) {
for (const amount of values(cleanedGive)) {
typeof amount.value === 'bigint' ||
Fail`multiples > 1 not yet implemented for non-fungibles: ${multiples} * ${amount}`;
}
}
assertExit(exit);
assertKeywordNotInBoth(cleanedWant, cleanedGive);
return cleanedProposal;
};

/**
*
* @param {Amount} amount
* @param {bigint} multiples
* @returns {Amount}
*/
export const scaleAmount = (amount, multiples) => {
if (multiples === 1n) {
return amount;
}
const { brand, value } = amount;
if (typeof value !== 'bigint') {
throw Fail`multiples > 1 not yet implemented for non-fungibles: ${multiples} * ${amount}`;
}
assert(value >= 1n);
return harden({ brand, value: natSafeMath.multiply(value, multiples) });
};
harden(scaleAmount);
79 changes: 50 additions & 29 deletions packages/zoe/src/contractFacet/offerSafety.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
import { AmountMath } from '@agoric/ertp';
import { natSafeMath } from '../contractSupport/safeMath.js';

const { Fail } = assert;
const { entries } = Object;

/**
* Helper to perform satisfiesWant and satisfiesGive. Is
* allocationAmount greater than or equal to requiredAmount for every
* keyword of giveOrWant?
*
* To prepare for multiples, satisfiesWant and satisfiesGive return 0 or 1.
* isOfferSafe will still be boolean. When we have Multiples, satisfiesWant and
* satisfiesGive will tell how many times the offer was matched.
* Helper to perform numWantsSatisfied and numGivesSatisfied. How many times
* does the `allocation` satisfy the `giveOrWant`?
*
* @param {AmountKeywordRecord} giveOrWant
* @param {AmountKeywordRecord} allocation
* @returns {0|1}
* @returns {number} If the giveOrWant is empty, then any allocation satisfies
* it an `Infinity` number of times.
*/
const satisfiesInternal = (giveOrWant = {}, allocation) => {
const isGTEByKeyword = ([keyword, requiredAmount]) => {
// If there is no allocation for a keyword, we know the giveOrWant
// is not satisfied without checking further.
const numSatisfied = (giveOrWant = {}, allocation) => {
let multiples = Infinity;
for (const [keyword, requiredAmount] of entries(giveOrWant)) {
if (allocation[keyword] === undefined) {
return 0;
}
const allocationAmount = allocation[keyword];
return AmountMath.isGTE(allocationAmount, requiredAmount) ? 1 : 0;
};
return Object.entries(giveOrWant).every(isGTEByKeyword) ? 1 : 0;
if (!AmountMath.isGTE(allocationAmount, requiredAmount)) {
return 0;
}
if (typeof requiredAmount.value !== 'bigint') {
multiples = 1;
} else if (requiredAmount.value > 0n) {
assert.typeof(allocationAmount.value, 'bigint');
const howMany = natSafeMath.floorDivide(
allocationAmount.value,
requiredAmount.value,
);
if (multiples > howMany) {
howMany <= Number.MAX_SAFE_INTEGER ||
Fail`numSatisfied ${howMany} out of safe integer range`;
multiples = Number(howMany);
}
}
}
return multiples;
};

/**
* For this allocation to satisfy what the user wanted, their
* allocated amounts must be greater than or equal to proposal.want.
* Even if multiples > 1n, this succeeds if it satisfies just one
* unit of want.
*
* @param {ProposalRecord} proposal - the rules that accompanied the
* escrow of payments that dictate what the user expected to get back
Expand All @@ -39,14 +56,17 @@ const satisfiesInternal = (giveOrWant = {}, allocation) => {
* @param {AmountKeywordRecord} allocation - a record with keywords
* as keys and amounts as values. These amounts are the reallocation
* to be given to a user.
* @returns {number} If the want is empty, then any allocation satisfies
* it an `Infinity` number of times.
*/
const satisfiesWant = (proposal, allocation) =>
satisfiesInternal(proposal.want, allocation);
export const numWantsSatisfied = (proposal, allocation) =>
numSatisfied(proposal.want, allocation);
harden(numWantsSatisfied);

/**
* For this allocation to count as a full refund, the allocated
* amounts must be greater than or equal to what was originally
* offered (proposal.give).
* offered (proposal.give * proposal.multiples).
*
* @param {ProposalRecord} proposal - the rules that accompanied the
* escrow of payments that dictate what the user expected to get back
Expand All @@ -57,9 +77,13 @@ const satisfiesWant = (proposal, allocation) =>
* @param {AmountKeywordRecord} allocation - a record with keywords
* as keys and amounts as values. These amounts are the reallocation
* to be given to a user.
* @returns {number} If the give is empty, then any allocation satisfies
* it an `Infinity` number of times.
*/
const satisfiesGive = (proposal, allocation) =>
satisfiesInternal(proposal.give, allocation);
// Commented out because not currently used
// const numGivesSatisfied = (proposal, allocation) =>
// numSatisfied(proposal.give, allocation);
// harden(numGivesSatisfied);

/**
* `isOfferSafe` checks offer safety for a single offer.
Expand All @@ -78,13 +102,10 @@ const satisfiesGive = (proposal, allocation) =>
* as keys and amounts as values. These amounts are the reallocation
* to be given to a user.
*/
function isOfferSafe(proposal, allocation) {
return (
satisfiesGive(proposal, allocation) > 0 ||
satisfiesWant(proposal, allocation) > 0
);
}

export const isOfferSafe = (proposal, allocation) => {
const { give, want, multiples } = proposal;
const howMany =
numSatisfied(give, allocation) + numSatisfied(want, allocation);
return howMany >= multiples;
};
harden(isOfferSafe);
harden(satisfiesWant);
export { isOfferSafe, satisfiesWant };
25 changes: 19 additions & 6 deletions packages/zoe/src/contractSupport/zoeHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { E } from '@endo/eventual-send';
import { makePromiseKit } from '@endo/promise-kit';
import { AssetKind } from '@agoric/ertp';
import { fromUniqueEntries } from '@agoric/internal';
import { satisfiesWant } from '../contractFacet/offerSafety.js';
import { atomicTransfer, fromOnly, toOnly } from './atomicTransfer.js';
import { numWantsSatisfied } from '../contractFacet/offerSafety.js';

export const defaultAcceptanceMsg = `The offer has been accepted. Once the contract has been completed, please check your payout`;

Expand Down Expand Up @@ -33,20 +33,25 @@ export const assertIssuerKeywords = (zcf, expected) => {
* check; whether the allocation constitutes a refund is not
* checked. The update is merged with currentAllocation
* (update's values prevailing if the keywords are the same)
* to produce the newAllocation. The return value is 0 for
* false and 1 for true. When multiples are introduced, any
* positive return value will mean true.
* to produce the newAllocation. The return value indicates the
* number of times the want was satisfied.
*
* There are some calls to `satisfies` dating from when it returned a
* boolean rather than a number. Manual inspection verifies that these
* are only sensitive to whether the result is truthy or falsy.
* Since `0` is falsy and any positive number (including `Infinity`)
* is truthy, all these callers still operate correctly.
*
* @param {ZCF} zcf
* @param {ZcfSeatPartial} seat
* @param {AmountKeywordRecord} update
* @returns {0|1}
* @returns {number}
*/
export const satisfies = (zcf, seat, update) => {
const currentAllocation = seat.getCurrentAllocation();
const newAllocation = { ...currentAllocation, ...update };
const proposal = seat.getProposal();
return satisfiesWant(proposal, newAllocation);
return numWantsSatisfied(proposal, newAllocation);
};

/** @type {Swap} */
Expand Down Expand Up @@ -160,6 +165,14 @@ export const assertProposalShape = (seat, expected) => {
assertKeys(actual.give, expected.give);
assertKeys(actual.want, expected.want);
assertKeys(actual.exit, expected.exit);
if ('multiples' in expected) {
// Not sure what to do with the value of expected.multiples. Probably
// nothing until we convert all this to use proper patterns
} else {
// multiples other than 1n need to be opted into
actual.multiples === 1n ||
Fail`Only 1n multiples expected: ${actual.multiples}`;
}
};

/* Given a brand, assert that brand is AssetKind.NAT. */
Expand Down
1 change: 1 addition & 0 deletions packages/zoe/src/typeGuards.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const TimerShape = makeHandleShape('timer');
export const FullProposalShape = harden({
want: AmountPatternKeywordRecordShape,
give: AmountKeywordRecordShape,
multiples: M.bigint(),
// To accept only one, we could use M.or rather than M.splitRecord,
// but the error messages would have been worse. Rather,
// cleanProposal's assertExit checks that there's exactly one.
Expand Down
7 changes: 4 additions & 3 deletions packages/zoe/src/zoeService/escrowStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { provideDurableWeakMapStore } from '@agoric/vat-data';
import './types.js';
import './internal-types.js';

import { cleanKeywords } from '../cleanProposal.js';
import { cleanKeywords, scaleAmount } from '../cleanProposal.js';
import { arrayToObj } from '../objArrayConversion.js';

/**
Expand Down Expand Up @@ -74,7 +74,7 @@ export const provideEscrowStorage = baggage => {

/** @type {DepositPayments} */
const depositPayments = async (proposal, payments) => {
const { give, want } = proposal;
const { give, want, multiples } = proposal;
const giveKeywords = Object.keys(give);
const wantKeywords = Object.keys(want);
const paymentKeywords = cleanKeywords(payments);
Expand Down Expand Up @@ -108,7 +108,8 @@ export const provideEscrowStorage = baggage => {
)} keyword in proposal.give did not have an associated payment in the paymentKeywordRecord, which had keywords: ${q(
paymentKeywords,
)}`;
return doDepositPayment(payments[keyword], give[keyword]);
const giveAmount = scaleAmount(give[keyword], multiples);
return doDepositPayment(payments[keyword], giveAmount);
}),
);

Expand Down
7 changes: 6 additions & 1 deletion packages/zoe/src/zoeService/offer/offer.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ export const makeOfferMethod = offerDataAccess => {
const proposal = cleanProposal(uncleanProposal, getAssetKindByBrand);
const proposalShape =
offerDataAccess.getProposalShapeForInvitation(invitationHandle);
if (proposalShape !== undefined) {
if (proposalShape === undefined) {
// For the contract to opt into accepting a multiples value other than
// `1n`, it must provide `makeInvitation` with a proposalShape.
proposal.multiples === 1n ||
Fail`Contract not willing to accept multiples for this invitation: ${proposal}`;
} else {
mustMatch(proposal, proposalShape, `${q(description)} proposal`);
}

Expand Down
12 changes: 7 additions & 5 deletions packages/zoe/src/zoeService/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,12 @@
* interact with the contract.
* @property {() => Promise<boolean>} hasExited
* Returns true if the seat has exited, false if it is still active.
* @property {() => Promise<0|1>} numWantsSatisfied returns 1 if the proposal's
* want clause was satisfied by the final allocation, otherwise 0. This is
* numeric to support a planned enhancement called "multiples" which will allow
* the return value to be any non-negative number. The promise will resolve
* after the seat has exited.
* @property {() => Promise<number>} numWantsSatisfied
* Returns the number of times that the proposal's `want` clause was satisfied
* by the final allocation. If the `want` was not satisfied then it was
* satisfied `0` times. If the want was satisfied, then it was satisfied
* `>= 1` times. The promise will resolve after the seat has exited.
*
* @property {() => Promise<Allocation>} getFinalAllocation
* return a promise for the final allocation. The promise will resolve after the
* seat has exited.
Expand All @@ -227,6 +228,7 @@
*
* @typedef {{give: AmountKeywordRecord,
* want: AmountKeywordRecord,
* multiples: bigint,
* exit: ExitRule
* }} ProposalRecord
*/
Expand Down
Loading

0 comments on commit 532cdb5

Please sign in to comment.