Tutorial

A simple tutorial meeting you with eoslime

Step by step walk

This tutorial is not runnable. Its purpose is only to show you some of the fundamental eoslime functionality

Let's up the level of difficulty.

Scenario: We are a simple Pizza shop. We have a contract and an account serving our shop.

Shop account has the following roles and responsibilities separated

  • Cooker - only he should be able to notify when a new Pizza has been cooked

  • Shop Manager - he could order more cooking products and withdraw the daily turnover. For the manager to withdraw the money, however, he should have the approval of the owners.

  • 2 Owners - they could withdraw the daily turnover

The contract should have the following functionality

  • Ready Pizza. It should send the cooked Pizza to its buyer.

  • Order cooking products

  • Order a Pizza. By ordering we are notifying our cooker to start making it

  • Buy one piece of the whole Pizza per transaction

  • Withdraw the daily turnover. At the end of the day, the shop manager should be able to withdraw the turnover

Our scenario has been set. Let's have fun and write some code

Define our tests border

const assert = require('assert');
const SHOP_WASM_PATH = './contracts/shopcontract.wasm';
const SHOP_ABI_PATH = './contracts/shopcontract.abi';
describe('Shop contract', function (eoslime) {
// Increase mocha(testing framework) time, otherwise tests fails
this.timeout(15000);
let shopContract;
let shopAccount;
let cooker;
before(async () => {
shopAccount = await eoslime.Account.createRandom();
shopContract = await eoslime.Contract.deploy(SHOP_WASM_PATH, SHOP_ABI_PATH);
});
});

Every owner will have a private/public keys pair. These pairs should be registered in the owner's authority of the account. In this way, only the owners will be able to manage the account.

const firstOwnerKeys = await eoslime.utils.generateKeys();
const secondOwnerKeys = await eoslime.utils.generateKeys();

We should load the shop account with the owner's authority so we will be able to modify the authorities in it. We need to load it because when creating a random account, it comes with generated private/public keys pair. This is some kind of a 'default' keys

shopAccount = eoslime.Account.load(shopAccount.name, shopAccount.executor.privateKey, 'owner');
await shopAccount.addAuthorityKey(firstOwnerKeys.publicKey)
await shopAccount.addAuthorityKey(secondOwnerKeys.publicKey);

Your final test suite for the above preparation could like such as

it('Should have two owners', async () => {
shopAccount = eoslime.Account.load(shopAccount.name, shopAccount.executor.privateKey, 'owner');
const firstOwnerKeys = await eoslime.utils.generateKeys();
const secondOwnerKeys = await eoslime.utils.generateKeys();
await shopAccount.addOnBehalfKey(firstOwnerKeys.publicKey)
await shopAccount.addOnBehalfKey(secondOwnerKeys.publicKey);
authorityInfo = await shopAccount.getAuthorityInfo();
// Check the owner authority has both owner keys
assert(authorityInfo.required_auth.keys.find((keyData) => { return keyData.key == keysPair.publicKey }));
});

Once we have our owners added to the shop account, let's add our shop manager.

Generate a random private/public keys pair for him and add his keys in a new custom authority called "shopmanager".

const shopManagerKeys = await eoslime.utils.generateKeys();
await shopAccount.addAuthority('shopmanager');

At the moment this authority is a brand new one and does not have access to anything in the blockchain. However, we need this person to be able to withdraw the daily turnover and order more cooking products.

We will start by giving our shop manager access to order cooking products

await shopAccount.setAuthorityAbilities('shopmanager', [
{ action: 'orderproducts', contract: shopContract.name }
]);
const shopManagerAuthority = eoslime.Account.load('name', 'key', 'shopmanager');

Nice job! We have created a custom authority and give some access to it. Let's continue with turnover access. To achieve this we will create another custom authority called "turnover". We need to configure it as a Multisignature one, so when the shop manager needs to withdraw the money, he should get at least one approval from the owners.

await shopAccount.addAuthority('turnover');
// Add withdraw access
await shopAccount.setAuthorityAbilities('turnover', [
{ action: 'withdraw', contract: shopContract.name }
]);
const shopManagerAuthority = eoslime.Account.load('name', 'key', 'turnover');

We have created the "turnover" authority because of the approvals. The "shopmanager" one does not need approvals to order more cooking products.

At the moment we have a brand new empty authority. Before adding its operators, let's set its approvals requisition.

await turnoverAuthority.increaseThreshold(2);

Each account's authority has a threshold value and each of the keys inside the authority have weight. We will set these values such as

  • Authority threshold = 2

  • Owners keys weight = 2

  • Shop manager keys weight = 1

The above configuration works as follow. Each of the owners is able to withdraw the turnover independently as their weight is at least equal to the threshold. The shop manager, however, needs his withdrawal transaction to be signed(approved) from at least one of the owners, because his weight is not enough.

const ownerWeight = 2;
const shopManagerWeight = 1;
await turnoverAuthority.addOnBehalfKey(firstOwnerKeys.publicKey, ownerWeight);
await turnoverAuthority.addOnBehalfKey(secondOwnerKeys.publicKey, ownerWeight);
await turnoverAuthority.addOnBehalfKey(shopManagerAuthority.publicKey, shopManagerWeight);

We prepared our shop manager. Yeeey! It is time to set up our cooker. For that reason, let's generate for him new keys and create an authority called "cooker".

const cookerAuthority = await shopAccount.addAuthority('cooker');
await shopAccount.setAuthorityAbilities('cooker', [
{ action: 'readypizza', contract: shopContract.name }
]);
const cookerAuthority = eoslime.Account.load('name', 'key', 'cooker');

Fantastic! We have prepared our shop account and our little shop is ready to open its doors. It is Pizza time! I almost forget to tell you about the Pizza price...

A piece will cost 2 EOS tokens. Buy-Pizza method will accept only one argument - the buyer name, so the contract could verify our buyers are the actual signers of their transactions. A bit of security stuff...

// Let's create our customer Bob
const bob = await eoslime.Account.createRandom();
// Buy some delisious Pizza piece
await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS" });

The object in the end contains options which are related to how the transaction will be structured and broadcasted

  • from - represents the signer of the transaction - Bob. To the moment our shopContract has its account as a signer of the transaction. If we leave it without from option, the shopContract will sign the transaction, but we need Bob to do it

  • tokens - In this way, you can process a transfer tokens in an atomic way. So you will process the buy action but you won't actually transfer tokens. You should do it in a separate transaction, but if it fails? You will register that you have bought a Pizza, but because of the transfer has failed, you did not give any tokens. tokens option package both of your transactions in an atomic one and if the transfer fails for some reason, you won't be able to buy a piece of Pizza

"Yummy Pizza!". Yeeah, Bob likes our Pizza and he wants to buy 2 more pieces.

await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS" });
await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS" });

Oppps.... Here you will get duplicate transaction error because you are trying to execute the same transaction twice...

To broadcast a transaction with the same parameters twice you have two choices

  • Wait for the first one to be processed and confirmed by the network and after that try to broadcast the second one

  • Force the transactions to be the unique while keeping the same parameters

await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS" });
await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS", unique: true });

We have our first client happy. Good work! Let's see how to order a whole Pizza this time.

await shopContract.actions.order(bob.name, { from: bob, tokens: "10.0000 EOS" });

So easy... Bob has ordered his Pizza

The Pizza has not been delivered yet! Whaaat?

Ouch... we forgot to notify our cooker.... Let's fix this for the feature

shopContract.actions.orderpizza.on('processed', (txReceipt, inputParams) => {
// Start cooking
await CookingService.startCooking();
// Once the Pizza becomes cooked, the cooker will notify the contract
// so it could deliver the pizza to its buyer
// inputParams[0] is bob's account name
await shopContract.actions.readypizza(cooker.name, inputParams[0], { from: cooker });
});
// Now, when someone orders a Pizza, our cooker will know he has a task
await shopContract.actions.orderpizza(bob.name, { from: bob, tokens: "10.0000 EOS" });
// Check a Pizza has been ordered
// Retrieve all pizzas bought from Bob in 'orders' table
const pizzas = await shopContract.tables.orders.equal(bob.name).find()
assert(pizzas[0].buyers == bob.name);

Our cheese seems to end very soon. Let's order more cooking products

await shopContract.actions.orderproduct('cheese', { from: shopManagerAuthority });
// Check a product order has been made
// Retrieve all products orders from 'coockingproducts' table
const orderedProducts = await shopContract.tables.coockingproducts.find();
assert(orderedProducts[0].product == 'cheese');

Our tutorial is coming to its end and our long first day in the shop also. It is time for the shop manager to withdraw the turnover.

Let's load the multisignature "turnover" authority

const turnoverMultiSigAuthority = eoslime.MultiSigAccount.load(shopAccount.name, shopManagerAuthority.privateKey, 'turnover');
turnoverMultiSigAuthority.loadKeys([firstOwnerKeys.privateKey, secondOwnerKeys.privateKey]);

Once loaded our shop manager will be able to request a withdrawal approval

// Withdraw does not need any parameters to be provided
// that is why the array at the end is empty
const proposalId = await turnoverMultiSigAuthority.propose(shopContract.withdraw.actions, []);
// The first owner approves the withdrawal
await turnoverMultiSigAuthority.approve(firstOwnerKeys.publicKey, proposalId);
// The shop manager is ready now to process the withdrawal
await turnoverMultiSigAuthority.processProposal(proposalId);

We had a successful first day in our little shop. Nice work!

Here is how the complete tests could like

const assert = require('assert');
const SHOP_WASM_PATH = './contracts/shopcontract.wasm';
const SHOP_ABI_PATH = './contracts/shopcontract.abi';
describe('Shop contract', function (eoslime) {
// Increase mocha(testing framework) time, otherwise tests fails
this.timeout(15000);
let shopContract;
let shopAccount;
let cooker;
before(async () => {
shopAccount = await eoslime.Account.createRandom();
shopContract = await eoslime.Contract.deploy(SHOP_WASM_PATH, SHOP_ABI_PATH);
});
it('Should have two owners', async () => {
shopAccount = eoslime.Account.load(shopAccount.name, shopAccount.executor.privateKey, 'owner');
const firstOwnerKeys = await eoslime.utils.generateKeys();
const secondOwnerKeys = await eoslime.utils.generateKeys();
await shopAccount.addOnBehalfKey(firstOwnerKeys.publicKey)
await shopAccount.addOnBehalfKey(secondOwnerKeys.publicKey);
authorityInfo = await shopAccount.getAuthorityInfo();
// Check the owner authority has both owner keys
assert(authorityInfo.required_auth.keys.find((keyData) => { return keyData.key == keysPair.publicKey }));
});
it('Should have a shop manager with the ability to withdraw the daily turnover', async () => {
const shopManagerKeys = await eoslime.utils.generateKeys();
await shopAccount.addAuthority('shopmanager');
await shopAccount.setAuthorityAbilities('shopmanager', [
{ action: 'orderproducts', contract: shopContract.name }
]);
const shopManagerAuthority = eoslime.Account.load(shopAccount.name, shopAccount.privateKey, 'shopmanager');
// Add shop manager keys
await shopManagerAuthority.addOnBehalfKey(shopManagerKeys.publicKey);
});
it('Should have a turnover authority with the ability to withdraw the daily turnover', async () => {
await shopAccount.addAuthority('turnover');
// Add withdraw access
await shopAccount.setAuthorityAbilities('turnover', [
{ action: 'withdraw', contract: shopContract.name }
]);
const turnoverAuthority = eoslime.Account.load(shopAccount.name, shopAccount.privateKey, 'turnover');
await turnoverAuthority.increaseThreshold(2);
const ownerWeight = 2;
const shopManagerWeight = 1;
await turnoverAuthority.addOnBehalfKey(firstOwnerKeys.publicKey, ownerWeight);
await turnoverAuthority.addOnBehalfKey(secondOwnerKeys.publicKey, ownerWeight);
await turnoverAuthority.addOnBehalfKey(shopManagerAuthority.publicKey, shopManagerWeight);
});
it('Should have a cooker', async () => {
await shopAccount.addAuthority('cooker');
// Add readypizza access
await shopAccount.setAuthorityAbilities('cooker', [
{ action: 'readypizza', contract: shopContract.name }
]);
});
it('Should be able for a customer to buy a piece of our Pizza', async () => {
const bob = await eoslime.Account.createRandom();
// Buy two pieces of the pizza
await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS" });
await shopContract.actions.buy(bob.name, { from: bob, tokens: "2.0000 EOS", unique: true });
});
it('Should be able for a customer to order a whole Pizza and receive it once it is ready', async () => {
shopContract.actions.orderpizza.on('processed', (txReceipt, inputParams) => {
await shopContract.actions.readypizza(cooker.name, inputParams[0], { from: cooker });
});
await shopContract.actions.orderpizza(bob.name, { from: bob, tokens: "10.0000 EOS" });
const orderedPizzas = await shopContract.tables.orderedpizzas.find();
assert(orderedPizzas[0].buyer == bob.name);
});
it('Should be able for shopper manager to order more cooking products', async () => {
await shopContract.actions.orderproduct('cheese', { from: shopManagerAuthority });
const orderedProducts = await shopContract.tables.coockingproducts.find();
assert(orderedProducts[0].product == 'cheese');
});
it('Should be able for shopper manager to withdraw the daily turnover', async () => {
const turnoverMultiSigAuthority = eoslime.MultiSigAccount.load(shopAccount.name, shopManagerAuthority.privateKey, 'turnover');
turnoverMultiSigAuthority.loadKeys([firstOwnerKeys.privateKey, secondOwnerKeys.privateKey]);
// Make a withdrawal proposal
const proposalId = await turnoverMultiSigAuthority.propose(shopContract.actions.withdraw, []);
// The first owner approves the withdrawal
await turnoverMultiSigAuthority.approve(firstOwnerKeys.publicKey, proposalId);
// Shop manager is able to process the withdrawal
await turnoverMultiSigAuthority.processProposal(proposalId);
// Check our shop account got the turnover
const shopBalance = await shopAccount.getBalance();
assert(shopBalance[0] == '14.0000 EOS', 'Incorrect tokens amount after send');
});
});