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 Feb 19, 2023
1 parent 4d513b0 commit c7cfdde
Show file tree
Hide file tree
Showing 18 changed files with 384 additions and 68 deletions.
7 changes: 6 additions & 1 deletion packages/inter-protocol/src/stakeFactory/stakeFactoryKit.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,12 @@ const helperBehavior = {
const want = proposal.want.Debt || emptyDebt;
const giveDebtOnly = matches(
proposal,
harden({ give: { [KW.Debt]: M.record() }, want: {}, exit: M.any() }),
harden({
give: { [KW.Debt]: M.record() },
want: {},
multiples: 1n,
exit: M.any(),
}),
);

// Calculate the fee, the amount to mint and the resulting debt. We'll
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ test.serial('errors', async t => {
error:
'Error: In "pushPrice" method of (OracleKit oracle): arg 0: unitPrice: number 1 - Must be a bigint',
// trivially satisfied because the Want is empty
numWantsSatisfied: 1,
numWantsSatisfied: Infinity,
},
);
await eventLoopIteration();
Expand All @@ -294,7 +294,7 @@ test.serial('errors', async t => {
}),
{
error: undefined,
numWantsSatisfied: 1,
numWantsSatisfied: Infinity,
},
);
await eventLoopIteration();
Expand All @@ -307,7 +307,7 @@ test.serial('errors', async t => {
}),
{
error: 'Error: cannot report on previous rounds',
numWantsSatisfied: 1,
numWantsSatisfied: Infinity,
},
);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/wallet/api/test/test-lib-wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,7 @@ test('lib-wallet can give attestations in offers', async t => {
give: {
Attestation: AmountMath.make(attestationBrand, 30n),
},
multiples: 1n,
want: {},
},
{
Expand All @@ -1365,6 +1366,7 @@ test('lib-wallet can give attestations in offers', async t => {
give: {
Attestation: AmountMath.make(attestationBrand, 30n),
},
multiples: 1n,
want: {},
},
]);
Expand Down Expand Up @@ -1428,6 +1430,7 @@ test('lib-wallet can want attestations in offers', async t => {
want: {
Attestation: AmountMath.make(attestationBrand, 65n),
},
multiples: 1n,
give: {},
},
]);
Expand All @@ -1444,6 +1447,7 @@ test('lib-wallet can want attestations in offers', async t => {
give: {
Attestation: AmountMath.make(attestationBrand, 65n),
},
multiples: 1n,
want: {},
},
]);
Expand Down Expand Up @@ -1551,6 +1555,7 @@ test('addOffer invitationQuery', async t => {
value: 1n,
},
},
multiples: 1n,
exit: {
onDemand: null,
},
Expand Down Expand Up @@ -1672,6 +1677,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,13 +3,13 @@ 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 {
atomicRearrange,
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 @@ -38,20 +38,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 @@ -167,6 +172,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 @@ -69,6 +69,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 @@ -46,7 +46,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
Loading

0 comments on commit c7cfdde

Please sign in to comment.