Introducing ICE: a task runner for the Internet Computer (like hardhat)

Hello IC devs! I’m excited to share a project I’ve been developing over the past few months. It’s called ICE (short for IC Experience).

ICE is a task runner for the IC, similar to hardhat. It’s goal is to become something like the “React” of dev tooling for the IC. Allowing developers to share and depend on each others work more easily.

It’s available on npm:

npm i -S @ice.ts/runner @ice.ts/canisters

deploy

Getting Started

The main repository is available at:

Want to take it for a spin? Check out this example project with 26 canisters deployed using a single command:

Core Features

Type-Safety Throughout

No more hand-writing Candid strings. Everything in ICE is fully typed, giving you instant feedback on configuration errors.

NPM install canister

I’ve ported the most common canisters to npm, available in the @ice.ts/canisters package. All complex setup steps have been abstracted away, allowing you to enable them with just a single line of code:

import {
  InternetIdentity,
  ICRC1Ledger,
  DIP721,
  Ledger,
  DIP20,
  CapRouter,
  NNS,
  CandidUI,
  ICRC7NFT,
  CyclesWallet,
  CyclesLedger,
  NFID,
} from "@ice.ts/canisters";

export const nns = NNS();
export const nfid = NFID();
export const icrc1 = ICRC1Ledger({ name: "My Test Token" });

Smart Defaults

ICE provides you convenient access to resources without requiring explicit configuration. Dependencies, environment variables, user identities, and network settings are automatically managed and made available in your tasks.

export const my_other_canister = motokoCanister({
  src: "canisters/my_other_canister/main.mo",
})
  .deps({ my_canister })
  .installArgs(async ({ deps }) => {
    const someVal = await deps.my_canister.actor.someMethod()
    return [deps.my_canister.canisterId, someVal]
  })

// Access to common resources through the context object
export const myTask = task("example")
  .run(async ({ ctx }) => {
    // Access user identities without manual setup
    console.log(ctx.users.default.principal);
    console.log(ctx.users.default.accountId);
    
    // Access network configuration
    console.log(ctx.network);
    
    // Dynamically run other tasks
    await ctx.runTask(anotherTask);
  });

VSCode Extension

There’s also a VSCode extension that enhances your development experience by allowing you to:

  • Run tasks directly from your editor with CodeLens
  • View actor call results inline in your code

vscode

There’s also an experimental terminal UI available throught the CLI, but honestly, after using the VSCode extension, it’s hard to go back.

Design Philosophy

While ICE draws inspiration from tools like Hardhat, its designed from first principles with composability and the Internet Computer in mind.

Pure Data vs. Side Effects

Unlike Hardhat, which relies on APIs like extendEnvironment() and task() that produce side effects without returning values, ICE embraces a pure data approach:

// Hardhat: Side-effect-based API
task("compile", "Compiles the entire project")
  .setAction(async () => { /* ... */ });

// ICE: Pure data approach
export const compile = task("compile")
  .run(async () => { /* ... */ });

This pure data approach means ICE tasks are first-class citizens that can be passed around, composed, and nested infinitely.

References vs. Strings

Hardhat refers to tasks using string identifiers:

// Hardhat: String-based references
await hre.run("compile");

ICE uses direct references instead:

// ICE: Direct references
await ctx.runTask(compile);

This design choice brings several advantages:

  • Type Safety: Your IDE can provide autocomplete and type checking
  • Refactoring Support: Rename a task, and all references update automatically
  • Better composability: Tasks and canisters may declare dependencies on other tasks

Roadmap

ICE is still in early development, and I’m actively working on several key features before it can be considered production-ready:

  • Caching for improved performance
  • Better handling of canister upgrades, reinstalls, etc.
  • Mainnet deployment
  • Enhanced context & configuration
  • More canisters included out of the box
  • Investigate Pocket-IC integration
  • Rust, Azle, Kybra canister builders
  • Comprehensive docs
  • e2e tests
    …and more

The long term goal is to revive my old projects (create-ic-app, connect2ic) and bring them under the ICE umbrella, but take them to the next level.

There’s still a long way to go, but hopefully I’ve managed to convey the power of this approach. I’m looking forward to your thoughts and feedback! :smiley: :smiley:

13 Likes

This looks very cool! Does it/could it have some crossover with pic.js? On first glance it looks like it could wrap some of the harder to grok concepts for getting started.

2 Likes

Thanks! And yes there is indeed a bit of crossover. Though the emphasis here is more on allowing developers to spin up any kind of environment easily and creating a set of abstractions that allow for greater composability.

I still need to figure out what the right approach here is, but you could perhaps use this to spin up the exact same environment, whether thats your local dev, testing with pic, or deploying to mainnet. without having to duplicate any logic.

1 Like

Are you interacting with Dax or some kind of virtual replica(or pocket ic)?

I’m wondering if you couldn’t just inject the pic server and overload whatever your task logic is.(perhaps this is what you are doing).

1 Like

Currently it’s still relying on the replica included in dfx, but all the interactions to it are made through the management canister API, so swapping it out should be fairly trivial.

Still early days, but the goal is to reach a point where an npm install is all you need.

1 Like

Hey @Tbd , long time no see!

Happy to see you back with such a banger, great work! :sunglasses:

I have a couple of questions:

  • how do you keep the canisters you’re installing in sync with what is deployed on mainnet
  • how do you figure out what init args you have to provide to resemble mainnet as close as possible
  • how did you understand what other canisters a canister depends on
  • how did you know if a canister needs to run on a system subnet or not
4 Likes

Hey long time, good to see you too!

Q: how do you keep the canisters you’re installing in sync with what is deployed on mainnet
A: it should be fairly trivial to implement a dfx pull like feature. This is more to demonstrate that canister configurations can be shared via npm. I imagine developers would want to have their own npm packages.
I just think it currently requires way too much work with the pull feature. Its not even a given that a canister we want to use must be deployed on mainnet. say our application has an icrc1 token, we wouldnt want to reuse a mainnet one then. And if theres some complex setup involved, then we’re basically screwed.

Q: how do you figure out what init args you have to provide to resemble mainnet as close as possible
A: Same. Currently the wasm & candid files live in the npm package.

Q: how did you understand what other canisters a canister depends on
A: This is manually specified, like:

export const MyCanister = () => motokoCanister({
  src: "canisters/my_canister/main.mo",
}).dependsOn({ icrc1_ledger })

Later if I import it from somewhere:

import { MyCanister } from "some-package"

// provide it:
export const my_canister = MyCanister().deps({ icrc1_ledger: some_other_icrc1_ledger })

It just compares the return types of the internal tasks which the canister is made up of (create, build, install, etc.), and produces a type-level warning if theres a mismatch. so in this case, my own icrc1_ledger implementations candid interface needs to match that of the one which MyCanister dependsOn.

Q: how did you know if a canister needs to run on a system subnet or not
A: The short answer is I dont yet. But its something I want to address. If one could also share subnet configurations and have everything self-contained, that would be great :sunglasses:

2 Likes