In one of my last blog posts, I shared a solution to query smart contracts on Internet Computer in a NodeJS context.
That article was the first of a series that will display the various scripts I have developed for new project called Papyrs - an open-source, privacy-first, decentralized blogging platform that lives 100% on chain.
I plan to share how I query remaining cycles and update the code of my users’ canisters. But first I’m going to share the basis of the architecture — i.e., to demonstrate the code that allows a program to dynamically create canisters.
Architecture
Unlike web2 projects that centralize user data in a single — or distributed — database, I adopted a more futuristic approach for the data persistence of Papyrs.
A main actor acts as a a manager that — on the fly, upon object creation — generates a decentralized, secure simple key-value database-like for each single data persistence of each user.
In following chapters I introduce such a solution with a sample project developed in Motoko.
For more details about this architecture, you can read an article I wrote about it: Internet Computer: Web App Decentralized Database Architecture.
Bucket
Each user gets a dedicated canister smart contract which I name a bucket
to follow the convention of the actor classes example provided by DFINITY on GitHub. On the forum it might sometimes also be referenced as "child" canister.
For demo purposes, I thought that such a bucket - an actor
- could expose a function that say
hello. Furthermore, to anticipate my future writings as well, it also receives a user
as parameter and contains a non stable version
number that could be incremented each time a new canister's code is installed.
Note that I use a Text
type for the user only to simplify the sample. In a real use case I would use a Principal
.
import Nat "mo:base/Nat";
actor class Bucket(user: Text) = this {
var version: Nat = 1;
public query func say() : async Text {
return "Hello World - " # user # " - v" # Nat.toText(version);
};
}
Manager
The manager - or Main
actor - contains more logic, so I will break it down into steps before presenting it in its entirety.
As mentioned before, it creates dynamic canisters. Therefore it can or should, depending on the use case, keep track of those that have been created — e.g., by stacking the canister ID that have been created within an Hashmap
.
However, for the sake of simplicity, in this article I only keep track of the last smart contract that has been initialized with the help of a stable variable canisterId
.
actor Main {
private stable var canisterId: ?Principal = null;
public query func getCanisterId() : async ?Principal {
canisterId
};
};
To create a new canister we mainly need two things:
- the bucket - i.e., the actor of previous chapter
- the cycles library
Because we implement the code of the bucket in the same project, we can include it with a relative import path. Each call to Bucket.Bucket(param)
instantiates a new bucket — i.e., dynamically creates a new canister smart contract.
The library is used to share the manager’s cycles with the bucket it creates. The related computational cost is 100,000,000,000 cycles — i.e., around $0.142, according to documentation.
import Cycles "mo:base/ExperimentalCycles";
import Principal "mo:base/Principal";
import Error "mo:base/Error";
import Bucket "./bucket";
actor Main {
private stable var canisterId: ?Principal = null;
public shared({ caller }) func init(): async (Principal) {
Cycles.add(1_000_000_000_000);
let b = await Bucket.Bucket("User1");
canisterId := ?(Principal.fromActor(b));
switch (canisterId) {
case null {
throw Error.reject("Bucket init error");
};
case (?canisterId) {
return canisterId;
};
};
};
public query func getCanisterId() : async ?Principal {
canisterId
};
};
While this might be the end of the story, I would like to add another piece to the puzzle.
Indeed, it might be interesting to set the controllers that can modify the bucket — e.g., it might be interesting to allow your principal and/or the one of the manager to update the code of the canisters.
For such purpose, we first need to add the specification of the Internet Computer to the project in form of a new Motoko module
. You can either convert the candid file or grab the one I used in Papyrs (source).
Finally, we can declare a variable that will be used to call the IC management canister address (aaaaa-aa
) and use it to effectively update the settings of the newly created canister.
import Cycles "mo:base/ExperimentalCycles";
import Principal "mo:base/Principal";
import Error "mo:base/Error";
import IC "./ic.types";
import Bucket "./bucket";
actor Main {
private stable var canisterId: ?Principal = null;
private let ic : IC.Self = actor "aaaaa-aa";
public shared({ caller }) func init(): async (Principal) {
Cycles.add(1_000_000_000_000);
let b = await Bucket.Bucket("User1");
canisterId := ?(Principal.fromActor(b));
switch (canisterId) {
case null {
throw Error.reject("Bucket init error");
};
case (?canisterId) {
let self: Principal = Principal.fromActor(Main);
let controllers: ?[Principal] = ?[canisterId, caller, self];
await ic.update_settings(({canister_id = canisterId; settings = {
controllers = controllers;
freezing_threshold = null;
memory_allocation = null;
compute_allocation = null;
}}));
return canisterId;
};
};
};
public query func getCanisterId() : async ?Principal {
canisterId
};
};
Web Application
With the two canister smart contracts being implemented, we can develop a dummy frontend to test their functionalities. It can contain two actions: one to create a bucket and another to call it — i.e., to call its function say
.
<html lang="en">
<body>
<main>
<button id="init">Init</button>
<button id="say">Say</button>
</main>
</body>
</html>
If you ever created a sample project with the dfx command line, following will feel familiar.
I created a project named buckets\_sample
. Dfx automatically installs the dependencies and a function that exposes the main
actor. Therefore the JavaScript function that calls the manager to instantiate a new canister uses these pre-made methods. I also save the bucket — the principal ID of the last canister that is created — in a global variable for reuse purpose.
import { buckets_sample } from "../../declarations/buckets_sample";
let bucket;
const initCanister = async () => {
try {
bucket = await buckets_sample.init();
console.log("New bucket:", bucket.toText());
} catch (err) {
console.error(err);
}
};
const init = () => {
const btnInit = document.querySelector("button#init");
btnInit.addEventListener("click", initCanister);
};
document.addEventListener("DOMContentLoaded", init);
On the contrary, the process that creates a new sample project is not aware that we want to dynamically create canisters. That is why we have to generate the candid interfaces and related JavaScript code for the bucket we have coded previously.
Currently there is no other way to generate these files than the following workaround:
1. Edit the configuration dfx.json
to list the bucket actor.
"canisters": {
"buckets_sample": {
"main": "src/buckets_sample/main.mo",
"type": "motoko"
},
"bucket": { <----- add an entry for the bucket
"main": "src/buckets_sample/bucket.mo",
"type": "motoko"
},
2. Run the dfx deploy
command to generate the files. The command will end in error ("Error: Invalid data: Expected arguments but found none.") that can safely be ignored 😉.
3. Revert the change in dfx.json
.
4. Copy the generated files to the source folder so that we can use them in the web application.
rsync -av .dfx/local/canisters/bucket ./src/declarations --exclude=bucket.wasm
A bit of mumbo jumbo but that does the trick 😁.
Thanks the newly generated declaration files, we can create a custom function that instantiate an actor for the bucket — canister ID — we generate on the fly.
import { Actor, HttpAgent } from "@dfinity/agent";
import { idlFactory } from "../../declarations/bucket";
export const createBucketActor = async ({ canisterId }) => {
const agent = new HttpAgent();
if (process.env.NODE_ENV !== "production") {
await agent.fetchRootKey();
}
return Actor.createActor(idlFactory, {
agent,
canisterId
});
};
Note in above snippet how I explicitly import
another idlFactory
, the one that matches the definition of the bucket.
We can ultimately implement the code that calls the say
function, which also ends the development of the demo application.
import { Actor, HttpAgent } from "@dfinity/agent";
import { buckets_sample } from "../../declarations/buckets_sample";
import { idlFactory } from "../../declarations/bucket";
export const createBucketActor = async ({ canisterId }) => {
const agent = new HttpAgent();
if (process.env.NODE_ENV !== "production") {
await agent.fetchRootKey();
}
return Actor.createActor(idlFactory, {
agent,
canisterId
});
};
let bucket;
const initCanister = async () => {
try {
bucket = await buckets_sample.init();
console.log("New bucket:", bucket.toText());
} catch (err) {
console.error(err);
}
};
const sayHello = async () => {
try {
const actor = await createBucketActor({
canisterId: bucket
});
console.log(await actor.say());
} catch (err) {
console.error(err);
}
};
const init = () => {
const btnInit = document.querySelector("button#init");
btnInit.addEventListener("click", initCanister);
const btnSay = document.querySelector("button#say");
btnSay.addEventListener("click", sayHello);
};
document.addEventListener("DOMContentLoaded", init);
Demo
Everything comes to an end, the sample project can finally be tested 😉.
The init
button dynamically creates a new canister, parse its ID in the console and the say
button calls the function of the new bucket.
Conclusion
It took me much longer than expected to write this article 😅. I hope it will be useful, and I am looking forward to share more tricks I learned while developing Papyrs.
Speaking of which, if you have any related questions or suggestions that would made interesting blog posts, reach out and let me know!?!
To infinity and beyond,
David