03 - KREAd Character Builder Walkthrough
Chapter 3 showcases a composable NFT dapp built-in Agoric
The concept
The Character Builder dApp enables the minting of NFTs representing a character. Character NFTs have an inventory which can hold Item. Items are visual accessories such as hair, masks, or backgrounds. Items are NFTs and can be owned by users, just like characters. Unlike other NFTs, however, they can be equipped to a character’s inventory in order to change the character's appearance and properties. Both assets types can be traded via a central marketplace or traded freely on other Agoric NFT dApps. Due to the composition aspect, when a character is transferred, access to its inventory is granted to the new owner.
Architecture
A few things to consider when designing an Agoric application:
- Contract composability: while it’s possible to write your entire app in custom smart contract, it’s worth checking for existing contracts that can support your dapp. For example, when developing a marketplace application, instead of writing the sell/buy logic in a single contract, you could have a contract that uses Agoric’s sellItems contract for listing assets. Perhaps later on you want to allow free trading of assets, making Agoric’s barterExchange contract a good fit. On that note, if you do write custom code to cover certain logic, consider sharing it for other developers to use.
- Offer safety guarantees: it is entirely possible to write an Agoric app without making use of offer safety. This would, however, require a level of trust from the users that could otherwise be avoided. For example, when a user accepts an offer proposal with an empty “want” property, they have no guarantee that any assets will be sent to their wallet. If the “want” property is properly formed, Offer Safety ensures they get what they expect.
- Thinking in terms of assets: identity in Agoric is different than web2 and most web3 solutions, rather than examining a user's credentials, it encourages the use of objects as a way to represent privileges or access across dApps. The added benefit is the ability to easily trade positions by transferring such objects. This opens up a lot of design options, consider taking the time to explore the possibilities.
Quick start
The following sections will explain the code that makes up the KREAd Character Builder, along with some design decisions and useful patterns. To follow along, clone the repo and run the following commands to run the dApp.
git clone <CHARACTER BUILDER REPO>
### Terminal 1 - ./agoric
agoric install
agoric start -v --reset
### Terminal 2 - ./agoric
agoric deploy contract/kread-deploy-contract.js api/kread-deploy-api.js
agoric open --repl
### Terminal 3 - ./frontend
# Remove `type: module` from `package.json`
yarn start
Project structure
The KREAd project is organized in two main directories:
- /agoric: Agoric SDK, and two subdirectories:
- /contract: contract code + contract deployment script
- /api: api deployment script
- /frontend: React application including the dapp UI, local-bridge web-socket connection, and constants file generated by the deployment scripts
Many example dapps provided by Agoric place the frontend code within the Agoric directory, and while this is completely valid, moving it outside can ease up conflicting yarn/npm configs between Agoric and your frontend solution of choice.
Contract initialization
Often times, contracts require external data to operate. For example, a fungible token contract might prompt whoever instantiates it for a string to be used as the token name. That way, rather than hardcoding the token name in the contract forcing all instances to use it, a custom name can be passed, effectively customizing the asset and making it much more reusable.
For KREAd, the contract requires a config object to be populated for most features to work. The function initConfig
takes in the necessary input and stores it in the contract state. This function can be made available via the Creator Facet, which only the actor creating the instance has access to. (Note: types and documentation are written in JSDoc.)
const STATE = {
config: undefined,
};
/**
* Set contract configuration, required for most contract features,
* base characters will be picked at random on new mint
* default items will be minted along each character
* seed is used to init the PRNG
*
* @param {{
* baseCharacters: any[],
* defaultItems: any[],
* seed: number
* chainTimerService: TimerService
* }} config
* @returns {string}
*/
const initConfig = ({
baseCharacters,
defaultItems,
seed,
chainTimerService,
}) => {
STATE.config = {
baseCharacters,
defaultItems,
chainTimerService,
completed: true,
};
assert(!Number.isNaN(seed), X`${errors.seedInvalid}`);
PRNG = mulberry32(seed);
STATE.randomNumber = PRNG;
return 'Setup completed';
};
const creatorFacet = Far('Kread Character Builder - Creator Facet', {
initConfig,
});
With composability in mind, rather than hardcoding NFT data on the contract layer, we allow the contract to receive an array of objects containing each base character and default items when initialized. This means anyone can deploy an instance of this contract to use their own set of NFT data. The api-deployment script is a good place to define and pass such data to the contract, by simply calling the initConfig function:
await E(kreadCreatorFacet).initConfig({
baseCharacters: updatedDefaultCharacters,
defaultItems: updatedDefaultItems,
chainTimerService,
}),
Note that the initConfig function also adds a “completed” flag to the config object, this can be used to refuse calls to the contract until it has been configured. One way to do this is using Agoric’s assert function as shown below.
import { assert } from '@agoric/assert';
const configDependentFn = () => {
// Throws if config not completed
assert(
STATE.config?.completed,
`Configuration not found, use creatorFacet.initConfig(<config>) to enable this method`,
);
};
Asset Requirements
KREAd’s main goal is the creation and handling of non-fungible assets, so let’s establish some requirements and go over how to implement them:
- Character and item NFTs must be immutable objects with a set of pre-defined properties.
- Character NFTs must contain a unique name, passed by its creator.
- A Character’s properties must be randomly selected from a predefined set at the time of minting, with the exception of the name (as stated in requirement 2). Users must not be able to bypass randomization.
- Character NFTs must be able to hold item NFTs.
- Users must be allowed to store and withdraw item NFTs any number of times within a character NFT they own. Only a single item of each item category can be stored at once.
Asset Architecture
Non-fungible tokens are immutable, meaning the data contained within cannot be changed once minted. This means pre-minting a set of assets to later transfer to users is not an option, since there’s no way to allow the user to change the name property after minting (based on requirement 2). Instead, KREAd provides the user with a mint invitation, which takes the name as input, selects a base character randomly, and mints the desired NFT. Let’s break this down:
Set up the Issuer Kit
First, let’s set up our assets to be NFTs by calling makeZCFMint and retrieving the resulting brand, issuer, and mint object for each asset:
const assetMints = await Promise.all([
zcf.makeZCFMint('KREA', AssetKind.SET),
zcf.makeZCFMint('KREAITEM', AssetKind.SET),
]);
const [
{ issuer: characterIssuer, brand: characterBrand },
{ issuer: itemIssuer, brand: itemBrand },
] = assetMints.map((mint) => mint.getIssuerRecord());
const [characterMint, itemMint] = assetMints;
Creating a mint invitation
Contract functions that deal with users and asset transfers must be exposed using ZCF’s makeInvitation method. In this case, as a method of the contract’s public facet, enabling any user with a reference to the instance to get a mint invitation.
const publicFacet = Far('Kread Character Builder - Public Facet', {
// Mint
makeMintCharacterInvitation: () =>
zcf.makeInvitation(mintCharacterNFT, 'mintCharacterNfts'),
});
One tip for developing smart contracts on Agoric is to check the type system shipped with the SDK to quickly understand the syntax and expected behavior. Consider enabling intellisense within your IDE for quick info. Here’s the type for the makeInvitation method:
/**
* @callback MakeInvitation
*
* Make a credible Zoe invitation for a particular smart contract
* indicated by the `instance` in the details of the invitation. Zoe
* also puts the `installation` and a unique `handle` in the details
* of the invitation. The contract must provide a `description` for
* the invitation and should include whatever information is necessary
* for a potential buyer of the invitation to know what they are
* getting in the `customProperties`. `customProperties` will be
* placed in the details of the invitation.
*
* @param {OfferHandler<OR>} offerHandler - a contract specific function
* that handles the offer, such as saving it or performing a trade
* @param {string} description
* @param {object=} customProperties
* @param {Pattern} [proposalSchema]
* @returns {Promise<Invitation<OR>>}
*/
We can go one layer further and inspect the OfferHandler
type in order to understand how to write the mintCharacterNFT function:
/**
* @typedef {(seat: ZCFSeat, offerArgs?: object) => OR} HandleOffer
*/
/**
* Mints a new character
* @param {ZCFSeat} seat
*/
const mintCharacterNFT = async (seat) => {
assert(STATE.config?.completed, X`${errors.noConfig}`);
// Mint
};
Mint Function
From the client + wallet, offers allows users to specify desired properties of an NFT within its want field, this means the user can use the offer to specify the property name of the Character they wish to receive, like so:
{
want: {
Asset: {
pursePetname: characterPursePetName,
value: [{ name }],
},
},
};
On the contract side, functions wrapped in zcf.makeInvitation
are called via Zoe Offers, the assertProposalShape
helper can check the want and give properties of the offer proposed by the caller. It ensures that the user expects to receive something under the keyword “Asset”. Omitting the give property indicates they won’t be sending any assets in return.
const mintCharacterNFT = async (seat) => {
assert(STATE.config?.completed, X`${errors.noConfig}`);
assertProposalShape(seat, {
want: {
Asset: null,
},
});
By parsing the want property via seat.getProposal
in the mint character function, the contract can get the name passed by the caller and add it to the NFT object before minting. Note that if the name property of the minted NFT does not match the one set by the user in the offer, the operation will fail due to a violation of offer safety.
In order to assert the name is unique, a name registry is added to the state and new names can be checked against it for uniqueness. The state object contains helper methods covering queries to state data, you can find the code here ${link.agoric.repoStateHelpers}.???
const { want } = seat.getProposal();
const newCharacterName = want.Asset.value[0].name;
assert(state.nameIsUnique(newCharacterName, STATE), X`${errors.nameTaken}`);
const currentTime = await state.getCurrentTime(STATE);
The first asset requirement states that character NFTs must consist of objects with a set of pre-defined properties. As discussed earlier, while it would be possible to hardcode these in the smart contract using the initConfig
function to allow each contract instance to pass said set of properties, greatly improves the contract’s reusability. Let’s use the baseCharacters
array passed on initConfig to select a random character and merge its properties with the unique name, an id, and the current timestamp.
/**
* @param {string} name
* @param {Object} randomCharacterBase
* @param {State} state
* @param currentTime
* @param newCharacterId
* @returns {Object[]}
*/
export const makeCharacterNftObjs = (
name,
randomCharacterBase,
currentTime,
newCharacterId,
) => {
// Merge random base character with name input, id, and keyId
const newCharacter1 = {
...randomCharacterBase,
date: currentTime,
id: newCharacterId,
name,
};
return [newCharacter1];
};
Going back to the mint character function, the `makeCharacterNftObjs helper forms the desired character objects and create an amount via AmountMath.make.
const [newCharacterAmount1] = makeCharacterNftObjs(
newCharacterName,
state.getRandomBaseCharacter(STATE),
STATE.characterCount,
currentTime,
).map((character) => AmountMath.make(characterBrand, harden([character])));
There are two ways of defining and minting payments in Agoric: makeIssuerKit and mintGains.
- ertp.makeIssuerKit + mintPayment: Creates and returns a new issuer and its associated mint and brand. All are in unchangeable one-to-one relationships with each other.
- zcf.makeZCFMint + mintGains: Creates a synchronous Zoe mint, allowing users to mint and reallocate digital assets synchronously instead of relying on an asynchronous ERTP mint.
You can find more details about each of these methods on the Agoric documentation here and here. KREAd uses the ZCF approach to synchronously reallocate the newly minted assets to the user seat. If makeIssuerKit is used instead, a payment is returned from mintPayment requiring allocation to the user seat as a separate step.
After calling mintGains, seat.exit performs the appropriate offer safety checks and reallocates the asset. Lastly, a message is returned to inform the user the mint operation succeeded.
// Mint character to user seat
characterMint.mintGains({ Asset: newCharacterAmount1 }, seat);
seat.exit();
return messages.mintCharacterReturn;
}
A seat represents a position in a given offer, and, since offers can handle multiple assets, they use keywords to identify the current allocation for each asset. Keywords can be chosen arbitrarily, and allow contract and users to refer to a given asset within a proposal.
intGains takes care of allocating to a seat, taking a KeywordRecord as first parameter. Earlier, AssertProposalShape was used to verify mintCharacterNFT offers included the keyword Asset
in the _want_ of the proposal, ensuring both contract and user are on the same page about the potential transfer of assets, in this case Character NFTs. Note that using a different keyword within mintGains would allocate the NFT to the wrong keyword, breaking the expectations defined by the user's offer proposal, violating Offer Safety, and resulting in an error.
With requirements 1-3 covered, the mint function looks as follows:
/**
* Mints a new character
* @param {ZCFSeat} seat
*/
const mintCharacterNFT = async (seat) => {
// Ensure config is complete
assert(STATE.config?.completed, X`${errors.noConfig}`);
// Check proposal keywords
assertProposalShape(seat, {
want: {
Asset: null,
},
});
// Get name from offer and check for uniqueness
const { want } = seat.getProposal();
const newCharacterName = want.Asset.value[0].name;
assert(state.nameIsUnique(newCharacterName, STATE), X`${errors.nameTaken}`);
// Form character nft amount
const currentTime = await state.getCurrentTime(STATE);
const [newCharacterAmount1] = makeCharacterNftObjs(
newCharacterName,
state.getRandomBaseCharacter(STATE),
STATE.characterCount,
currentTime,
).map((character) => AmountMath.make(characterBrand, harden([character])));
// Mint character to user seat
characterMint.mintGains({ Asset: newCharacterAmount1 }, seat);
seat.exit();
return messages.mintCharacterReturn;
};
/**
* @param {string} name
* @param {Object} randomCharacterBase
* @param {State} state
* @param currentTime
* @param newCharacterId
* @returns {Object[]}
*/
export const makeCharacterNftObjs = (
name,
randomCharacterBase,
currentTime,
newCharacterId,
) => {
// Merge random base character with name input, id, and keyId
const newCharacter1 = {
...randomCharacterBase,
date: currentTime,
id: newCharacterId,
name,
keyId: 1,
};
return [newCharacter1];
};
Onto the next challenge: provide a way to mint a set of default items, and enable the character NFT to hold them. Conceptually, you can think of this as each character having an inventory, from which item NFTs can be equipped and unequipped.
To achieve this functionality, let’s create an empty seat and store it in the contract state, along with a reference to the Character it “belongs” to. This way each character will be able to hold arbitrary assets within a seat in the contract instance.
Let’s add the following code to the mint character function to mint a set of default items and store them in the newly created empty seat.
// Create empty seat representing inventory
const { zcfSeat: inventorySeat } = zcf.makeEmptySeatKit();
// Mint items to inventory seat
const allDefaultItems = Object.values(STATE.config.defaultItems);
const itemsAmount = AmountMath.make(itemBrand, harden(uniqueItems));
itemMint.mintGains({ Item: itemsAmount }, inventorySeat);
Once the character and default items have been successfully minted, let’s add them to the contract state to keep a record of the new character and its inventory seat.
const character = {
name: newCharacterName,
character: newCharacterAmount1.value[0],
inventory: inventorySeat,
};
STATE.characters = [...STATE.characters, character];
A simple array will suffice for keeping track of character records, and since the name property is unique, let’s add it to the character record object to be used for lookups later. At the time of writing, Array.push()
resulted in an error, hence the use of the spread operator instead.
So far, asset requirements one through four are covered. Calling mintCharacterNFT mints a randomly selected base character with a unique name passed by the user, creates an inventory seat, mints default items to it, and it transfers the character NFT to the user.
For the final requirement, let’s implement a way to equip and unequip items from a character inventory seat. Since the equip and unequip methods are meant to handle assets, they will once again be exposed via zcf.makeInvitation. A naive attempt of the equip function would be simply to inspect the offer to ensure an item is sent, locate the corresponding inventory, and reallocate the item to it:
/**
* Adds item to inventory
* @param {ZCFSeat} seat
*/
const equip = async (seat) => {
assert(STATE.config?.completed, X`${errors.noConfig}`);
assertProposalShape(seat, {
give: {
Item: null,
},
});
// Retrieve Items and Inventory key from user seat
const providedItemAmount = seat.getAmountAllocated('Item');
// Find characterRecord entry based on provided key
const characterRecord = state.getCharacterRecord(characterName, STATE);
const inventorySeat = characterRecord.inventory;
// Widthdraw Item from user seat
seat.decrementBy({ Item: providedItemAmount });
// Deposit Item and Key to inventory seat
inventorySeat.incrementBy({ Item: providedItemAmount });
zcf.reallocate(seat, inventorySeat);
seat.exit();
};
The issue with the code above is that the user has no way of expressing which character they wish to equip an item to, leaving the contract unable to find the corresponding inventory seat. And even if the character name is passed an offer argument, it wouldn’t be possible to verify whether the caller is the owner of that Character, or simply knows its name.
Web2 solutions often rely on username-password credentials for access control, and while a similar mechanism could be implemented for KREAd, an NFT-based method can leverage existing Agoric features and simplify the process. Specifically, escrowing the character NFT during the equip and unequip operations as a means of access control offers some interesting advantages:
- Agoric smart contracts have no knowledge of the user calling its methods, in other words, a contract cannot differentiate between two users making the same offer to it. Escrowing the NFT as access does not rely on identity, the only thing we need to check is whether a user owns the Character, the identity of the user is irrelevant.
- NFTs are unique digital assets that can only exist in a single wallet, seat, or offer at a time. This means that, unlike username and password credentials, they can’t be shared and used by multiple users simultaneously, much like a physical key to a lock. If multiple NFTs were valid for access control we’d have to consider racing conditions, by using a unique digital asset as the “key” to the inventory, we are guaranteed that only one user is able to access it at any given point.
- The last of our asset requirements states that a character owner must have exclusive access to its inventory, this implies that transferring a character automatically grants access to the corresponding inventory. Using the character as the key itself enables exactly this kind of access, without resorting to identifying the user via credentials or wallet information.
Currently, there are some limitations when it comes to implementing escrow of assets for a given contract invitation. To understand this, let’s formalize the desired access control logic:
- The user escrows the character NFT within the user seat of the equip/unequip method, along with any items to equip/unequip.
- The contract verifies the character is valid and uses its name to locate its inventory.
- The contract performs the transfer of items based on the method.
- The contract returns the character NFT back to the user, along with any items that were unequipped.
- The user should be guaranteed that the escrowed NFT will returned once the operation is executed.
To implement the escrow making use of offer safety, we can form the following proposal from the frontend:
{
give: {
Asset: {
pursePetname: characterPursePetName,
value: [characterNFT],
},
Item: { // Omit if unequip method
pursePetname: characterPursePetName,
value: [itemToEquip],
},
want: {
Asset: {
pursePetname: characterPursePetName,
value: [characterNFT],
},
Item: { // Omit if equip method
pursePetname: characterPursePetName,
value: [itemToUnEquip],
},
};
The problem is that currently, ERTP does not allow the same NFT to be in both the want and give properties of a single offer. When it comes to calculating asset payouts for an offer, it doesn’t make sense for a party to receive exactly what it paid. For use-case like KREAd, however, asset escrow at the offer level does prove useful.
To bypass this limitation, we’ll mint two NFTs for each new character, same data except for a property keyId (with values 1 and 2). One of the NFTs will be minted to the user while the other one will go to the inventory, this way each call to inventory methods can propose an exchange of the two [equally valid] character NFTs, circumventing the issue of expressing escrow in offer proposals.
See the adjusted makeCharacterNftObjs and mintCharacterNFT below for implementation details:
/**
* @param {string} name
* @param {Object} randomCharacterBase
* @param {State} state
* @param currentTime
* @param newCharacterId
* @returns {Object[]}
*/
export const makeCharacterNftObjs = (
name,
randomCharacterBase,
currentTime,
newCharacterId,
) => {
// Merge random base character with name input, id, and keyId
const newCharacter1 = {
...randomCharacterBase,
date: currentTime,
id: newCharacterId,
name,
keyId: 1,
};
const newCharacter2 = {
...randomCharacterBase,
date: currentTime,
id: newCharacterId,
name,
keyId: 2,
};
return [newCharacter1, newCharacter2];
};
/**
* Mints a new character
*
* @param {ZCFSeat} seat
*/
const mintCharacterNFT = async (seat) => {
assert(STATE.config?.completed, X`${errors.noConfig}`);
assertProposalShape(seat, {
want: {
Asset: null,
},
});
const { want } = seat.getProposal();
const newCharacterName = want.Asset.value[0].name;
assert(state.nameIsUnique(newCharacterName, STATE), X`${errors.nameTaken}`);
const currentTime = await state.getCurrentTime(STATE);
const [newCharacterAmount1, newCharacterAmount2] = makeCharacterNftObjs(
newCharacterName,
state.getRandomBaseCharacter(STATE),
STATE.characterCount,
currentTime,
).map((character) => AmountMath.make(characterBrand, harden([character])));
const { zcfSeat: inventorySeat } = zcf.makeEmptySeatKit();
// Mint character to user seat & inventorySeat
characterMint.mintGains({ Asset: newCharacterAmount1 }, seat);
characterMint.mintGains({ CharacterKey: newCharacterAmount2 }, inventorySeat);
// Mint items to inventory seat
const allDefaultItems = Object.values(STATE.config.defaultItems);
const uniqueItems = allDefaultItems.map((item) => {
const newItemWithId = {
...item,
id: STATE.itemCount,
};
STATE.itemCount = 1n + STATE.itemCount;
STATE.characterCount = 1n + STATE.characterCount;
return newItemWithId;
});
const itemsAmount = AmountMath.make(itemBrand, harden(uniqueItems));
itemMint.mintGains({ Item: itemsAmount }, inventorySeat);
seat.exit();
return messages.mintCharacterReturn;
};