Complete Contract Walk-Through
Let's look at the contract from the basic dapp in some detail.
Bundling a Contract
In deploying the basic dapp contract, the first step was to bundle all of its modules into a single artifact. We used the agoric run command in that case. The core mechanism used in agoric run
is a call to bundleSource()
.
In the contract
directory of the dapp, run test-bundle-source.js
following ava
conventions:
cd contract
yarn ava test/test-bundle-source.js
The results look something like...
✔ bundleSource() bundles the contract for use with zoe (2.7s)
ℹ 1e1aeca9d3ebc0bd39130fe5ef6fbb077177753563db522d6623886da9b43515816df825f7ebcb009cbe86dcaf70f93b9b8595d1a87c2ab9951ee7a32ad8e572
ℹ Object @Alleged: BundleInstallation {}
─
1 test passed
Test Setup
The test uses createRequire
from the node module
API to resolve the main module specifier:
import bundleSource from '@endo/bundle-source';
import { createRequire } from 'module';
const myRequire = createRequire(import.meta.url);
const contractPath = myRequire.resolve(`../src/gameAssetContract.js`);
bundleSource()
returns a bundle object with moduleFormat
, a hash, and the contents:
const bundle = await bundleSource(contractPath);
t.is(bundle.moduleFormat, 'endoZipBase64');
t.log(bundle.endoZipBase64Sha512);
t.true(bundle.endoZipBase64.length > 10_000);
Getting the zip file from inside a bundle
An endo bundle is a zip file inside JSON. To get it back out:
jq -r .endoZipBase64 bundle-xyz.json | base64 -d >xyz.zip
You can then, for example, look at its contents:
unzip -l xyz.zip
Contract Installation
To identify the code of contracts that parties consent to participate in, Zoe uses Installation objects.
Let's try it with the contract from our basic dapp:
yarn ava test/test-contract.js -m 'Install the contract'
✔ Install the contract
ℹ Object @Alleged: BundleInstallation {}
Test Setup
The test starts by using makeZoeKitForTest
to set up zoe for testing:
import { makeZoeKitForTest } from '@agoric/zoe/tools/setup-zoe.js';
const { zoeService: zoe } = makeZoeKitForTest();
It gets an installation using a bundle as in the previous section:
const installation = await E(zoe).install(bundle);
t.log(installation);
t.is(typeof installation, 'object');
The installation
identifies the basic contract that we'll go over in detail in the sections below.
gameAssetContract.js listing
/** @file Contract to mint and sell Place NFTs for a hypothetical game. */
// @ts-check
import { Far } from '@endo/far';
import { M, getCopyBagEntries } from '@endo/patterns';
import { AmountMath, AssetKind } from '@agoric/ertp/src/amountMath.js';
import { AmountShape } from '@agoric/ertp/src/typeGuards.js';
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);
/** @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 {ZCF<{joinPrice: Amount}>} zcf
*/
export const start = async zcf => {
const { joinPrice } = zcf.getTerms();
const { zcfSeat: gameSeat } = zcf.makeEmptySeatKit();
const mint = await zcf.makeZCFMint('Place', AssetKind.COPY_BAG);
/** @param {ZCFSeat} playerSeat */
const joinHandler = playerSeat => {
const { give, want } = playerSeat.getProposal();
trace('join', 'give', give, 'want', want.Places.value);
AmountMath.isGTE(give.Price, joinPrice) ||
Fail`${q(give.Price)} below joinPrice of ${q(joinPrice)}}`;
bagValueSize(want.Places) <= 3n || Fail`only 3 places allowed when joining`;
const tmp = mint.mintGains(want);
atomicRearrange(
zcf,
harden([
[playerSeat, gameSeat, give],
[tmp, playerSeat, want],
]),
);
playerSeat.exit(true);
return 'welcome to the game';
};
const joinShape = harden({
give: { Price: AmountShape },
want: { Places: AmountShape },
exit: M.any(),
});
const publicFacet = Far('API', {
makeJoinInvitation: () =>
zcf.makeInvitation(joinHandler, 'join', undefined, joinShape),
});
return { publicFacet };
};
harden(start);
Starting a Contract Instance
Now we're ready to start an instance of the basic dapp contract:
yarn ava test/test-contract.js -m 'Start the contract'
✔ Start the contract (652ms)
ℹ terms: {
joinPrice: {
brand: Object @Alleged: PlayMoney brand {},
value: 5n,
},
}
ℹ Object @Alleged: InstanceHandle {}
Contracts can be parameterized by terms. The price of joining the game is not fixed in the source code of this contract, but rather chosen when starting an instance of the contract. Likewise, when starting an instance, we can choose which asset issuers the contract should use for its business:
const money = makeIssuerKit('PlayMoney');
const issuers = { Price: money.issuer };
const terms = { joinPrice: AmountMath.make(money.brand, 5n) };
t.log('terms:', terms);
/** @type {ERef<Installation<GameContractFn>>} */
const installation = E(zoe).install(bundle);
const { instance } = await E(zoe).startInstance(installation, issuers, terms);
t.log(instance);
t.is(typeof instance, 'object');
makeIssuerKit
and AmountMath.make
are covered in the ERTP section, along with makeEmptyPurse
, mintPayment
, and getAmountOf
below.
See also E(zoe).startInstance(...).
Let's take a look at what happens in the contract when it starts. A facet of Zoe, the Zoe Contract Facet, is passed to the contract start
function. The contract uses this zcf
to get its terms. Likewise it uses zcf
to make a gameSeat
where it can store assets that it receives in trade as well as a mint
for making assets consisting of collections (bags) of Places:
export const start = async zcf => {
const { joinPrice } = zcf.getTerms();
const { zcfSeat: gameSeat } = zcf.makeEmptySeatKit();
const mint = await zcf.makeZCFMint('Place', AssetKind.COPY_BAG);
It defines a joinShape
and joinHandler
but doesn't do anything with them yet. They will come into play later. It defines and returns its publicFacet
and stands by.
return { publicFacet };
Trading with Offer Safety
Our basic dapp includes a test of trading:
yarn ava test/test-contract.js -m 'Alice trades*'
✔ Alice trades: give some play money, want some game places (674ms)
ℹ Object @Alleged: InstanceHandle {}
ℹ Alice gives {
Price: {
brand: Object @Alleged: PlayMoney brand {},
value: 5n,
},
}
ℹ Alice payout brand Object @Alleged: Place brand {}
ℹ Alice payout value Object @copyBag {
payload: [
[
'Park Place',
1n,
],
[
'Boardwalk',
1n,
],
],
}
We start by putting some money in a purse for Alice:
const alicePurse = money.issuer.makeEmptyPurse();
const amountOfMoney = AmountMath.make(money.brand, 10n);
const moneyPayment = money.mint.mintPayment(amountOfMoney);
alicePurse.deposit(moneyPayment);
Then we pass the contract instance and the purse to our code for alice
:
await alice(t, zoe, instance, alicePurse);
Alice starts by using the instance
to get the contract's publicFacet
and terms
from Zoe:
const publicFacet = E(zoe).getPublicFacet(instance);
const terms = await E(zoe).getTerms(instance);
const { issuers, brands, joinPrice } = terms;
Then she constructs a proposal to give the joinPrice
in exchange for 1 Park Place and 1 Boardwalk, denominated in the game's Place
brand; and she withdraws a payment from her purse:
const choices = ['Park Place', 'Boardwalk'];
const choiceBag = makeCopyBag(choices.map(name => [name, 1n]));
const proposal = {
give: { Price: joinPrice },
want: { Places: AmountMath.make(brands.Place, choiceBag) },
};
const Price = await E(purse).withdraw(joinPrice);
t.log('Alice gives', proposal.give);
She then requests an invitation to join the game; makes an offer with (a promise for) this invitation, her proposal, and her payment; and awaits her Places payout:
const toJoin = E(publicFacet).makeJoinInvitation();
const seat = E(zoe).offer(toJoin, proposal, { Price });
const places = await E(seat).getPayout('Places');
Troubleshooting missing brands in offers
If you see...
Error#1: key Object [Alleged: IST brand] {} not found in collection brandToIssuerRecord
then it may be that your offer uses brands that are not known to the contract. Use E(zoe).getTerms() to find out what issuers are known to the contract.
If you're writing or instantiating the contract, you can tell the contract about issuers when you are creating an instance or by using zcf.saveIssuer().
The contract gets Alice's E(publicFacet).makeJoinInvitation()
call and uses zcf
to make an invitation with an associated handler, description, and proposal shape. Zoe gets Alice's E(zoe).offer(...)
call, checks the proposal against the proposal shape, escrows the payment, and invokes the handler.
const joinShape = harden({
give: { Price: AmountShape },
want: { Places: AmountShape },
exit: M.any(),
});
const publicFacet = Far('API', {
makeJoinInvitation: () =>
zcf.makeInvitation(joinHandler, 'join', undefined, joinShape),
});
The offer handler is invoked with a seat representing the party making the offer. It extracts the give
and want
from the party's offer and checks that they are giving at least the joinPrice
and not asking for too many places in return.
With all these prerequisites met, the handler instructs zcf
to mint the requested Place assets, allocate what the player is giving into its own gameSeat
, and allocate the minted places to the player. Finally, it concludes its business with the player.
/** @param {ZCFSeat} playerSeat */
const joinHandler = playerSeat => {
const { give, want } = playerSeat.getProposal();
trace('join', 'give', give, 'want', want.Places.value);
AmountMath.isGTE(give.Price, joinPrice) ||
Fail`${q(give.Price)} below joinPrice of ${q(joinPrice)}}`;
bagValueSize(want.Places) <= 3n || Fail`only 3 places allowed when joining`;
const tmp = mint.mintGains(want);
atomicRearrange(
zcf,
harden([
[playerSeat, gameSeat, give],
[tmp, playerSeat, want],
]),
);
playerSeat.exit(true);
return 'welcome to the game';
};
Zoe checks that the contract's instructions are consistent with the offer and with conservation of assets. Then it allocates the escrowed payment to the contract's gameSeat and pays out the place NFTs to Alice in response to the earlier getPayout(...)
call.
Alice asks the Place
issuer what her payout is worth and tests that it's what she wanted.
const actual = await E(issuers.Place).getAmountOf(places);
t.log('Alice payout brand', actual.brand);
t.log('Alice payout value', actual.value);
t.deepEqual(actual, proposal.want.Places);