From c431e0008621388199c98bc16a4b45c99a092095 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 4 Jan 2024 14:35:45 -0600 Subject: [PATCH 1/2] chore: rename to avoid "game" --- .../{build-game1-start.js => build-contract-start.js} | 10 ++++------ contract/src/{gameAssetContract.js => offerItems.js} | 0 .../{start-game1-proposal.js => start-offer-items.js} | 0 3 files changed, 4 insertions(+), 6 deletions(-) rename contract/scripts/{build-game1-start.js => build-contract-start.js} (77%) rename contract/src/{gameAssetContract.js => offerItems.js} (100%) rename contract/src/{start-game1-proposal.js => start-offer-items.js} (100%) diff --git a/contract/scripts/build-game1-start.js b/contract/scripts/build-contract-start.js similarity index 77% rename from contract/scripts/build-game1-start.js rename to contract/scripts/build-contract-start.js index 29f5f45..d85b0f8 100644 --- a/contract/scripts/build-game1-start.js +++ b/contract/scripts/build-contract-start.js @@ -6,7 +6,7 @@ */ import { makeHelpers } from '@agoric/deploy-script-support'; -import { getManifestForGame1 } from '../src/start-game1-proposal.js'; +import { getManifestForGame1 } from '../src/start-offer-items.js'; /** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ export const game1ProposalBuilder = async ({ publishRef, install }) => { @@ -16,11 +16,9 @@ export const game1ProposalBuilder = async ({ publishRef, install }) => { getManifestForGame1.name, { game1Ref: publishRef( - install( - '../src/gameAssetContract.js', - '../bundles/bundle-game1.js', - { persist: true }, - ), + install('../src/gameAssetContract.js', '../bundles/bundle-game1.js', { + persist: true, + }), ), }, ], diff --git a/contract/src/gameAssetContract.js b/contract/src/offerItems.js similarity index 100% rename from contract/src/gameAssetContract.js rename to contract/src/offerItems.js diff --git a/contract/src/start-game1-proposal.js b/contract/src/start-offer-items.js similarity index 100% rename from contract/src/start-game1-proposal.js rename to contract/src/start-offer-items.js From 64d432e68937f6586582cabb0f72e3ec672b56f2 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 4 Jan 2024 14:38:17 -0600 Subject: [PATCH 2/2] chore!: document contract; avoid "game" (WIP) TODO: update ui - rename files etc. to avoid "game" - rename brands, keywords, issuers, ... - migrate from reallocate() to atomicRearrange - constrain brands, bag amount in proposal shape - skip tracer() - refactor bag count utilities for clarity --- contract/package.json | 2 +- contract/scripts/build-contract-start.js | 14 +-- contract/src/offerItems.js | 147 +++++++++++++++++------ contract/src/start-offer-items.js | 57 +++++---- contract/test/test-bundle-source.js | 2 +- contract/test/test-contract.js | 58 +++++---- 6 files changed, 171 insertions(+), 109 deletions(-) diff --git a/contract/package.json b/contract/package.json index 7f2a044..00c57ad 100644 --- a/contract/package.json +++ b/contract/package.json @@ -11,7 +11,7 @@ "docker:make": "docker compose exec agd make -C /workspace/contract", "make:help": "make list", "start": "yarn docker:make clean start-contract print-key", - "build": "agoric run scripts/build-game1-start.js", + "build": "agoric run scripts/build-contract-start.js", "test": "ava --verbose", "lint": "eslint '**/*.js'", "lint:fix": "eslint --fix '**/*.js'" diff --git a/contract/scripts/build-contract-start.js b/contract/scripts/build-contract-start.js index d85b0f8..00a7704 100644 --- a/contract/scripts/build-contract-start.js +++ b/contract/scripts/build-contract-start.js @@ -6,17 +6,17 @@ */ import { makeHelpers } from '@agoric/deploy-script-support'; -import { getManifestForGame1 } from '../src/start-offer-items.js'; +import { getManifest } from '../src/start-offer-items.js'; /** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ -export const game1ProposalBuilder = async ({ publishRef, install }) => { +export const proposalBuilder = async ({ publishRef, install }) => { return harden({ - sourceSpec: '../src/start-game1-proposal.js', + sourceSpec: '../src/start-offer-items.js', getManifestCall: [ - getManifestForGame1.name, + getManifest.name, { - game1Ref: publishRef( - install('../src/gameAssetContract.js', '../bundles/bundle-game1.js', { + offerItemsRef: publishRef( + install('../src/offerItems.js', '../bundles/bundle-offerItems.js', { persist: true, }), ), @@ -28,5 +28,5 @@ export const game1ProposalBuilder = async ({ publishRef, install }) => { /** @type {DeployScriptFunction} */ export default async (homeP, endowments) => { const { writeCoreProposal } = await makeHelpers(homeP, endowments); - await writeCoreProposal('start-game1', game1ProposalBuilder); + await writeCoreProposal('start-contract', proposalBuilder); }; diff --git a/contract/src/offerItems.js b/contract/src/offerItems.js index 90c0cfc..f3021ab 100644 --- a/contract/src/offerItems.js +++ b/contract/src/offerItems.js @@ -1,70 +1,137 @@ -/** @file Contract to mint and sell Place NFTs for a hypothetical game. */ +/** + * @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 + * + * @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 { M, getCopyBagEntries, mustMatch } from '@endo/patterns'; import { AmountMath, 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('Game', true); +// #region bag utilities +/** @type { (xs: bigint[]) => bigint } */ +const sum = xs => xs.reduce((acc, x) => acc + x, 0n); -/** @param {Amount<'copyBag'>} amt */ -const bagValueSize = amt => { - /** @type {[unknown, bigint][]} */ - const entries = getCopyBagEntries(amt.value); // XXX getCopyBagEntries returns any??? - const total = entries.reduce((acc, [_place, qty]) => acc + qty, 0n); - return total; +/** + * @param {import('@endo/patterns').CopyBag} bag + * @returns {bigint[]} + */ +const bagCounts = bag => { + const entries = getCopyBagEntries(bag); + return entries.map(([_k, ct]) => ct); }; +// #endregion /** - * @param {ZCF<{joinPrice: Amount}>} zcf + * This contract is parameterized by terms for price and, + * optionally, a maximum number of items sold for that price (default: 3). + * + * @typedef {{ + * price: Amount; + * maxItems?: bigint; + * }} ItemTerms + */ + +/** + * 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. + * + * As is typical in Zoe contracts, the flow is: + * 1. contract does internal setup + * 2. client uses a public facet method -- {@link makeBuyItemsInvitation} 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 buyItemsHandler}. + * + * @param {ZCF} zcf */ export const start = async zcf => { - const { joinPrice } = zcf.getTerms(); + const { price, maxItems = 3n } = zcf.getTerms(); + // Static types aren't enforced on callers of start(), + // so use patterns to check supplied terms at runtime. + mustMatch(price, AmountShape); + mustMatch(maxItems, M.bigint()); - const { zcfSeat: gameSeat } = zcf.makeEmptySeatKit(); - const mint = await zcf.makeZCFMint('Place', 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 joinShape = harden({ - give: { Price: AmountShape }, - want: { Places: AmountShape }, + /** + * a pattern to constrain proposals given to {@link buyItemsHandler} + * + * The `Pay` amount must use the brand from the `price` term. + * The `Items` amount must use the `Item` brand and a bag value. + */ + const proposalShape = harden({ + give: { Pay: { brand: price.brand, value: M.any() } }, + want: { Items: { brand: itemBrand, value: M.bag() } }, exit: M.any(), }); - /** @param {ZCFSeat} playerSeat */ - const joinHook = playerSeat => { - const { give, want } = playerSeat.getProposal(); - trace('join', 'give', give, 'want', want.Places.value); + /** a seat for allocating proceeds of sales */ + const proceeds = zcf.makeEmptySeatKit().zcfSeat; + + /** @param {ZCFSeat} buyerSeat */ + const buyItemsHandler = buyerSeat => { + // give and want are guaranteed by Zoe to match proposalShape + const { give, want } = buyerSeat.getProposal(); - AmountMath.isGTE(give.Price, joinPrice) || - Fail`${q(give.Price)} below joinPrice of ${q(joinPrice)}}`; + AmountMath.isGTE(give.Pay, price) || + Fail`${q(give.Pay)} below price of ${q(price)}}`; - bagValueSize(want.Places) <= 3n || Fail`only 3 places allowed when joining`; + 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 - playerSeat.decrementBy(gameSeat.incrementBy(give)); - const tmp = mint.mintGains(want); - playerSeat.incrementBy(tmp.decrementBy(want)); - zcf.reallocate(playerSeat, tmp, gameSeat); + const newItems = itemMint.mintGains(want); + atomicRearrange( + zcf, + harden([ + // price from buyer to proceeds + [buyerSeat, proceeds, { Pay: price }], + // new items to buyer + [newItems, buyerSeat, want], + ]), + ); - playerSeat.exit(true); - return 'welcome to the game'; + buyerSeat.exit(true); + return 'trade complete'; }; - const publicFacet = Far('API', { - makeJoinInvitation: () => - zcf.makeInvitation(joinHook, 'join', undefined, joinShape), - }); + /** + * Make an invitation to buy items. + * + * Proposal Keywords used in offers using these invitations: + * - give: `Pay` + * - want: `Items` + */ + const makeBuyItemsInvitation = () => + zcf.makeInvitation(buyItemsHandler, 'buy items', undefined, proposalShape); + // Mark the publicFacet Far, i.e. reachable from outside the contract + const publicFacet = Far('Items Public Facet', { + makeBuyItemsInvitation, + }); return harden({ publicFacet }); }; harden(start); diff --git a/contract/src/start-offer-items.js b/contract/src/start-offer-items.js index 53c113e..f7ea772 100644 --- a/contract/src/start-offer-items.js +++ b/contract/src/start-offer-items.js @@ -3,7 +3,7 @@ import { E } from '@endo/far'; import { makeMarshal } from '@endo/marshal'; import { AmountMath } from '@agoric/ertp/src/amountMath.js'; -console.warn('start-game1-proposal.js module evaluating'); +console.warn('start-offer-items.js module evaluating'); const { Fail } = assert; @@ -41,48 +41,47 @@ const publishBrandInfo = async (chainStorage, board, brand) => { * * @param {BootstrapPowers} permittedPowers */ -export const startGameContract = async permittedPowers => { - console.error('startGameContract()...'); +export const startOfferItemsContract = async permittedPowers => { + console.error('startOfferItemsContract()...'); const { consume: { board, chainStorage, startUpgradable, zoe }, brand: { consume: { IST: istBrandP }, // @ts-expect-error dynamic extension to promise space - produce: { Place: producePlaceBrand }, + produce: { OfferItems: produceBrand }, }, issuer: { consume: { IST: istIssuerP }, // @ts-expect-error dynamic extension to promise space - produce: { Place: producePlaceIssuer }, + produce: { OfferItems: produceIssuer }, }, installation: { - consume: { game1: game1InstallationP }, + consume: { offerItems: installationP }, }, instance: { // @ts-expect-error dynamic extension to promise space - produce: { game1: produceInstance }, + produce: { offerItems1: produceInstance }, }, } = permittedPowers; const istIssuer = await istIssuerP; const istBrand = await istBrandP; - // NOTE: joinPrice could be configurable - const terms = { joinPrice: AmountMath.make(istBrand, 25n * CENT) }; + const terms = { price: AmountMath.make(istBrand, 25n * CENT) }; // agoricNames gets updated each time; the promise space only once XXXXXXX - const installation = await game1InstallationP; + const installation = await installationP; const { instance } = await E(startUpgradable)({ installation, - issuerKeywordRecord: { Price: istIssuer }, - label: 'game1', + issuerKeywordRecord: { Pay: istIssuer }, + label: 'offer-items1', terms, }); - console.log('CoreEval script: started game contract', instance); + console.log('CoreEval script: started offer-items contract', instance); const { - brands: { Place: brand }, - issuers: { Place: issuer }, + brands: { Pay: brand }, + issuers: { Item: issuer }, } = await E(zoe).getTerms(instance); console.log('CoreEval script: share via agoricNames:', brand); @@ -90,18 +89,18 @@ export const startGameContract = async permittedPowers => { produceInstance.reset(); produceInstance.resolve(instance); - producePlaceBrand.reset(); - producePlaceIssuer.reset(); - producePlaceBrand.resolve(brand); - producePlaceIssuer.resolve(issuer); + produceBrand.reset(); + produceIssuer.reset(); + produceBrand.resolve(brand); + produceIssuer.resolve(issuer); await publishBrandInfo(chainStorage, board, brand); - console.log('game1 (re)installed'); + console.log('offerItems1 (re)started'); }; /** @type { import("@agoric/vats/src/core/lib-boot").BootstrapManifest } */ -const gameManifest = { - [startGameContract.name]: { +const contractManifest = { + [startOfferItemsContract.name]: { consume: { agoricNames: true, board: true, // to publish boardAux info for game NFT @@ -109,17 +108,17 @@ const gameManifest = { startUpgradable: true, // to start contract and save adminFacet zoe: true, // to get contract terms, including issuer/brand }, - installation: { consume: { game1: true } }, - issuer: { consume: { IST: true }, produce: { Place: true } }, - brand: { consume: { IST: true }, produce: { Place: true } }, - instance: { produce: { game1: true } }, + installation: { consume: { offerItems: true } }, + issuer: { consume: { IST: true }, produce: { OfferItems: true } }, + brand: { consume: { IST: true }, produce: { OfferItems: true } }, + instance: { produce: { offerItems1: true } }, }, }; -harden(gameManifest); +harden(contractManifest); -export const getManifestForGame1 = ({ restoreRef }, { game1Ref }) => { +export const getManifest = ({ restoreRef }, { game1Ref }) => { return harden({ - manifest: gameManifest, + manifest: contractManifest, installations: { game1: restoreRef(game1Ref), }, diff --git a/contract/test/test-bundle-source.js b/contract/test/test-bundle-source.js index 5b2ef34..ed2fe72 100644 --- a/contract/test/test-bundle-source.js +++ b/contract/test/test-bundle-source.js @@ -12,7 +12,7 @@ import { E, passStyleOf } from '@endo/far'; import { makeZoeKitForTest } from '@agoric/zoe/tools/setup-zoe.js'; const myRequire = createRequire(import.meta.url); -const contractPath = myRequire.resolve(`../src/gameAssetContract.js`); +const contractPath = myRequire.resolve(`../src/offerItems.js`); test('bundleSource() bundles the contract for use with zoe', async t => { const bundle = await bundleSource(contractPath); diff --git a/contract/test/test-contract.js b/contract/test/test-contract.js index 44e9581..5488875 100644 --- a/contract/test/test-contract.js +++ b/contract/test/test-contract.js @@ -15,12 +15,12 @@ import { makeZoeKitForTest } from '@agoric/zoe/tools/setup-zoe.js'; import { AmountMath, makeIssuerKit } from '@agoric/ertp'; import { makeStableFaucet } from './mintStable.js'; -import { startGameContract } from '../src/start-game1-proposal.js'; +import { startOfferItemsContract } from '../src/start-offer-items.js'; -/** @typedef {typeof import('../src/gameAssetContract.js').start} GameContractFn */ +/** @typedef {typeof import('../src/offerItems.js').start} ItemContractFn */ const myRequire = createRequire(import.meta.url); -const contractPath = myRequire.resolve(`../src/gameAssetContract.js`); +const contractPath = myRequire.resolve(`../src/offerItems.js`); /** @type {import('ava').TestFn>>} */ const test = anyTest; @@ -62,11 +62,11 @@ test('Start the contract', async t => { const { zoe, bundle } = t.context; const money = makeIssuerKit('PlayMoney'); - const issuers = { Price: money.issuer }; - const terms = { joinPrice: AmountMath.make(money.brand, 5n) }; + const issuers = { Pay: money.issuer }; + const terms = { price: AmountMath.make(money.brand, 5n) }; t.log('terms:', terms); - /** @type {ERef>} */ + /** @type {ERef>} */ const installation = E(zoe).install(bundle); const { instance } = await E(zoe).startInstance(installation, issuers, terms); t.log(instance); @@ -78,7 +78,7 @@ test('Start the contract', async t => { * * @param {import('ava').ExecutionContext} t * @param {ZoeService} zoe - * @param {ERef} instance + * @param {ERef} instance * @param {Purse} purse * @param {string[]} choices */ @@ -92,36 +92,36 @@ const alice = async ( const publicFacet = E(zoe).getPublicFacet(instance); // @ts-expect-error Promise seems to work const terms = await E(zoe).getTerms(instance); - const { issuers, brands, joinPrice } = terms; + const { issuers, brands, price } = terms; const choiceBag = makeCopyBag(choices.map(name => [name, 1n])); const proposal = { - give: { Price: joinPrice }, - want: { Places: AmountMath.make(brands.Place, choiceBag) }, + give: { Pay: price }, + want: { Items: AmountMath.make(brands.Item, choiceBag) }, }; - const pmt = await E(purse).withdraw(joinPrice); + const pmt = await E(purse).withdraw(price); t.log('Alice gives', proposal.give); // #endregion makeProposal - const toJoin = E(publicFacet).makeJoinInvitation(); + const toBuy = E(publicFacet).makeBuyItemsInvitation(); - const seat = E(zoe).offer(toJoin, proposal, { Price: pmt }); - const places = await E(seat).getPayout('Places'); + const seat = E(zoe).offer(toBuy, proposal, { Pay: pmt }); + const items = await E(seat).getPayout('Items'); - const actual = await E(issuers.Place).getAmountOf(places); + const actual = await E(issuers.Item).getAmountOf(items); t.log('Alice payout brand', actual.brand); t.log('Alice payout value', actual.value); - t.deepEqual(actual, proposal.want.Places); + t.deepEqual(actual, proposal.want.Items); }; test('Alice trades: give some play money, want some game places', async t => { const { zoe, bundle } = t.context; const money = makeIssuerKit('PlayMoney'); - const issuers = { Price: money.issuer }; - const terms = { joinPrice: AmountMath.make(money.brand, 5n) }; + const issuers = { Pay: money.issuer }; + const terms = { price: AmountMath.make(money.brand, 5n) }; - /** @type {ERef>} */ + /** @type {ERef>} */ const installation = E(zoe).install(bundle); const { instance } = await E(zoe).startInstance(installation, issuers, terms); t.log(instance); @@ -142,16 +142,12 @@ test('Trade in IST rather than play money', async t => { * @param {{ zoe: ZoeService, bundle: {} }} powers */ const startContract = async ({ zoe, bundle }) => { - /** @type {ERef>} */ + /** @type {ERef>} */ const installation = E(zoe).install(bundle); const feeIssuer = await E(zoe).getFeeIssuer(); const feeBrand = await E(feeIssuer).getBrand(); - const joinPrice = AmountMath.make(feeBrand, 25n * CENT); - return E(zoe).startInstance( - installation, - { Price: feeIssuer }, - { joinPrice }, - ); + const price = AmountMath.make(feeBrand, 25n * CENT); + return E(zoe).startInstance(installation, { Pay: feeIssuer }, { price }); }; const { zoe, bundle, bundleCache, feeMintAccess } = t.context; @@ -203,14 +199,14 @@ test('use the code that will go on chain to start the contract', async t => { consume: { zoe, chainStorage, startUpgradable, board }, brand: { consume: { IST: pFor(feeBrand) }, - produce: { Place: sync.brand }, + produce: { OfferItems: sync.brand }, }, issuer: { consume: { IST: pFor(feeIssuer) }, - produce: { Place: sync.issuer }, + produce: { OfferItems: sync.issuer }, }, - installation: { consume: { game1: sync.installation.promise } }, - instance: { produce: { game1: sync.instance } }, + installation: { consume: { offerItems: sync.installation.promise } }, + instance: { produce: { offerItems1: sync.instance } }, }; return powers; }; @@ -225,7 +221,7 @@ test('use the code that will go on chain to start the contract', async t => { // When the BLD staker governance proposal passes, // the startup function gets called. - await startGameContract(powers); + await startOfferItemsContract(powers); const instance = await sync.instance.promise; // Now that we have the instance, resume testing as above.