Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: document contract; refactor for clarity; use defensive patterns #49

Merged
merged 2 commits into from
Jan 24, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 109 additions & 38 deletions contract/src/offer-up.contract.js
Original file line number Diff line number Diff line change
@@ -1,70 +1,141 @@
/** @file Contract to mint and sell Item NFTs. */
/**
* @file Contract to mint and sell a few Item NFTs at a time.
*
* We declare variables (including functions) before using them,
* so you may want to skip ahead and come back to some details.
* @see {start} for the main contract entrypoint
*
* As is typical in Zoe contracts, the flow is:
* 1. contract does internal setup and returns public / creator facets.
* 2. client uses a public facet method -- {@link makeTradeInvitation} in this case --
* to make an invitation.
* 3. client makes an offer using the invitation, along with
* a proposal (with give and want) and payments. Zoe escrows the payments, and then
* 4. Zoe invokes the offer handler specified in step 2 -- here {@link tradeHandler}.
*
* @see {@link https://docs.agoric.com/guides/zoe/|Zoe Overview} for a walk-thru of this contract
* @see {@link https://docs.agoric.com/guides/js-programming/hardened-js.html|Hardened JavaScript}
* for background on `harden` and `assert`.
*/
// @ts-check

import { Far } from '@endo/far';
import { M, getCopyBagEntries } from '@endo/patterns';
import { AmountMath, AssetKind } from '@agoric/ertp/src/amountMath.js';
import { AssetKind } from '@agoric/ertp/src/amountMath.js';
import { AmountShape } from '@agoric/ertp/src/typeGuards.js';
// Use the deprecated atomicRearrange API
// for compatibility with mainnet1B.
import { atomicRearrange } from '@agoric/zoe/src/contractSupport/atomicTransfer.js';
import '@agoric/zoe/exported.js';

import { makeTracer } from './debug.js';

const { Fail, quote: q } = assert;

const trace = makeTracer('OfferUp', true);
// #region bag utilities
/** @type { (xs: bigint[]) => bigint } */
const sum = xs => xs.reduce((acc, x) => acc + x, 0n);

/**
* @param {import('@endo/patterns').CopyBag} bag
* @returns {bigint[]}
*/
const bagCounts = bag => {
const entries = getCopyBagEntries(bag);
return entries.map(([_k, ct]) => ct);
};
// #endregion

/**
* In addition to the standard `issuers` and `brands` terms,
* this contract is parameterized by terms for price and,
* optionally, a maximum number of items sold for that price (default: 3).
*
Copy link
Member

@0xpatrickdev 0xpatrickdev Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
*
* These terms will extend `StandardTerms` present in all zoe contracts, `issuers` and `brands`.
*

Don't feel strongly about this suggestion but could be helpful.

* @typedef {{
* tradePrice: Amount;
* maxItems?: bigint;
* }} OfferUpTerms
*/

/** @param {Amount<'copyBag'>} amt */
const bagValueSize = amt => {
/** @type {[unknown, bigint][]} */
const entries = getCopyBagEntries(amt.value); // XXX getCopyBagEntries returns any???
const total = entries.reduce((acc, [_, qty]) => acc + qty, 0n);
return total;
export const meta = {
customTermsShape: M.splitRecord(
{ tradePrice: AmountShape },
{ maxItems: M.bigint() },
),
};
// compatibility with an earlier contract metadata API
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean? I have not seen meta.customTermsShape before.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's like a proposal shape but for terms.

Documentation is on the TODO list:

That issue has pointers to context.

export const customTermsShape = meta.customTermsShape;

/**
* @param {ZCF<{tradePrice: Amount}>} zcf
* Start a contract that
* - creates a new non-fungible asset type for Items, and
* - handles offers to buy up to `maxItems` items at a time.
*
* @param {ZCF<OfferUpTerms>} zcf
*/
export const start = async zcf => {
const { tradePrice } = zcf.getTerms();
const { tradePrice, maxItems = 3n } = zcf.getTerms();

const { zcfSeat: proceeds } = zcf.makeEmptySeatKit();
const mint = await zcf.makeZCFMint('Item', AssetKind.COPY_BAG);
/**
* a new ERTP mint for items, accessed thru the Zoe Contract Facet.
* Note: `makeZCFMint` makes the associated brand and issuer available
* in the contract's terms.
*
* AssetKind.COPY_BAG can express non-fungible (or rather: semi-fungible)
* amounts such as: 3 potions and 1 map.
*/
const itemMint = await zcf.makeZCFMint('Item', AssetKind.COPY_BAG);
const { brand: itemBrand } = itemMint.getIssuerRecord();

const tradeShape = harden({
give: { Price: AmountShape },
want: { Items: AmountShape },
/**
* a pattern to constrain proposals given to {@link tradeHandler}
*
* The `Price` amount must be >= `tradePrice` term.
* The `Items` amount must use the `Item` brand and a bag value.
*/
const proposalShape = harden({
give: { Price: M.gte(tradePrice) },
want: { Items: { brand: itemBrand, value: M.bag() } },
exit: M.any(),
});

/** @param {ZCFSeat} buyerSeat */
const tradeHandler = buyerSeat => {
const { give, want } = buyerSeat.getProposal();
trace('trade', 'give', give, 'want', want.Items.value);
/** a seat for allocating proceeds of sales */
const proceeds = zcf.makeEmptySeatKit().zcfSeat;

AmountMath.isGTE(give.Price, tradePrice) ||
Fail`${q(give.Price)} below required ${q(tradePrice)}}`;
/** @type {OfferHandler} */
const tradeHandler = buyerSeat => {
// give and want are guaranteed by Zoe to match proposalShape
const { want } = buyerSeat.getProposal();

bagValueSize(want.Items) <= 3n || Fail`only 3 items allowed in a trade`;
sum(bagCounts(want.Items.value)) <= maxItems ||
Fail`max ${q(maxItems)} items allowed: ${q(want.Items)}`;

// We use the deprecated stage/reallocate API
// so that we can test this with the version of zoe on mainnet1B.
// using atomicRearrange bloated the contract from ~1MB to ~3BM
buyerSeat.decrementBy(proceeds.incrementBy(give));
const tmp = mint.mintGains(want);
buyerSeat.incrementBy(tmp.decrementBy(want));
zcf.reallocate(buyerSeat, tmp, proceeds);
const newItems = itemMint.mintGains(want);
atomicRearrange(
zcf,
harden([
// price from buyer to proceeds
[buyerSeat, proceeds, { Price: tradePrice }],
// new items to buyer
[newItems, buyerSeat, want],
]),
);

buyerSeat.exit(true);
newItems.exit();
return 'trade complete';
};

const publicFacet = Far('API', {
makeTradeInvitation: () =>
zcf.makeInvitation(tradeHandler, 'trade', undefined, tradeShape),
});
/**
* Make an invitation to trade for items.
*
* Proposal Keywords used in offers using these invitations:
* - give: `Price`
* - want: `Items`
*/
const makeTradeInvitation = () =>
zcf.makeInvitation(tradeHandler, 'buy items', undefined, proposalShape);

// Mark the publicFacet Far, i.e. reachable from outside the contract
const publicFacet = Far('Items Public Facet', {
makeTradeInvitation,
});
return harden({ publicFacet });
};
harden(start);