A Canister Market

Let’s extend our escrow canister to a market canister: a trusted third party that enables the safe exchange of canisters for tokens.

Recall our escrow canister implemented the methods:

  • account_id: returns an account ID to which a caller should deposit tokens in order to buy canisters.

  • withdraw: refunds all tokens to given account, minus the transaction fee. (In contrast, our escrow canister withdraw method always withdraws to the caller’s main account.)

To these we add:

  • post: takes sole control of a given canister ID and lists it for sale at given price; before calling, the seller should add the market canister as one of the controllers of the canister.

  • unpost: undoes a post, that is, restores the original controllers of the canister and removes it from the list of canisters for sale.

  • catalog: returns the list of canisters for sale.

  • buy: transfers the price of a given canister from the caller’s escrow account to the seller’s main account, and on success, gives control of the canister ID to the caller. We also add the market as a controller, so the new owner can easily repost the canister for sale again.

service : {
  account_id : () -> (text) query;
  withdraw : (blob) -> ();
  post : (record { price : nat64; canister_id : principal}) -> ();
  unpost : (nat64) -> ();
  buy : (nat64) -> ();
  catalog : () -> (vec record {
    lot : nat64;
    canister_id : principal;
    price : nat64;
    seller : principal;
    controllers : vec principal;
  }) query;

We must be mindful of the asynchronous nature of calls. We err on the side of simplicity: a single locked global prevents any interleaving of calls made during the execution of any of these methods; for example, if post is waiting to hear back from a call, then a withdraw call fails; the caller should try again later.

It might be safe to interleave certain kinds of calls, or add extra logic to handle interleaved calls, but such optimizations may be overkill. Our methods only make calls to the ledger and management canisters, which we expect to return quickly.

The locked global means we must avoid reject system calls in callbacks, because reverting wasm memory would lock up our canister!

We must prevent bait-and-switch scams. Bob should not be able to post a canister, then unpost and post again with a higher price just before Alice’s buy call is processed. Similary, Bob should not be able to post a canister, then unpost, then upgrade the canister before reposting, causing Alice to buy something else than that was advertised.

Our solution is to associate each catalog entry with a strictly increasing index. On a post, this index is incremented and assigned to catalog entry, namely, a particular price and canister that is now controlled by the market canister. A buy call refers to a catalog entry by this index.

Suppose Bob attempts a bait-and-switch. His unpost call succeeds if it is processed before Alice’s buy call, but a second post call assigns a new index to his canister, and the buy call simply fails.

We must also prevent Bob from posting the same canister twice. This is enforced by our simple locking scheme, and checking that the caller is a controller of a posted canister. The first time Bob posts his canister, the market takes sole control of it. If Bob attemps to post the canister again, nothing happens because Bob is no longer a controller; only the market is.

We write market.c which compiles to market.wasm:


We create a bunch of identities:

dfx identity new minter
dfx identity new alice
dfx identity new bob

In a new subdirectory, and fetch the ledger canister:

export IC_VERSION=dd3a710b03bd3ae10368a91b255571d012d1ec2f
curl -o ledger.wasm.gz https://download.dfinity.systems/ic/${IC_VERSION}/canisters/ledger-canister_notify-method.wasm.gz
gunzip ledger.wasm.gz

Copy market.wasm and hello.wasm to this directory, setup dfx.json, and start a replica:

touch nothing
cat > dfx.json << EOF
dfx start

Start another terminal. Thanks to our Rebel Candid tool, we can deploy the ledger without downloading any Candid interface files. We feed a template of the initial ledger argument to rebel to encode the message for us:

echo 'record {minting_account = text $MINT_ACC; initial_values = vec { record { text $ALICE_ACC; record { e8s=nat64 100_000_000_000 } } }; send_whitelist = vec principal {}}' | rebel

Unlike other canisters, the ledger initialization argument expects account IDs to be written in hexdecimal in text fields! As we’re crafting a raw message, we must hex-encode this text a second time, and also prepend each account ID with 0x40, the length of each text field.

dfx identity use minter
MINT_ACC=$(echo -n 40; echo -n $(dfx ledger account-id) | basenc --base16 -w0)
dfx identity use alice
ALICE_ACC=$(echo -n 40; echo -n $(dfx ledger account-id) | basenc --base16 -w0)
dfx deploy ledger --argument-type raw --argument 4449444c056d686c01e0a9b302786c02007101016d026c0390c5a3a90100aecbeb880471fdbacfcc0d03010400"$MINT_ACC"01"$ALICE_ACC"00e8764817000000

Alice now has 1000 ICP:

LEDGER=$(grep -A1 ledger .dfx/local/canister_ids.json | sed -n '/local/p' | sed 's/.*"\([^"]*\)"$/\1/')
dfx ledger balance --ledger-canister-id $LEDGER

Alice deploys the market canister, and transfers 42 ICP to her escrow account:

dfx deploy market
dfx ledger transfer --ledger-canister-id $LEDGER $(dfx canister call market account_id | grep -o '[a-z0-9]*') --amount 42 --memo 0

Now Bob deploys a canister, and puts it up for sale by adding the market canister as a controller, then calling the post method. The price is 12.34 ICP.

dfx identity use bob
dfx deploy hello
HELLO=$(grep -A1 hello .dfx/local/canister_ids.json | sed -n '/local/p' | sed 's/.*"\([^"]*\)"$/\1/')
MARKET=$(grep -A1 market .dfx/local/canister_ids.json | sed -n '/local/p' | sed 's/.*"\([^"]*\)"$/\1/')
dfx canister update-settings --add-controller $MARKET hello
dfx canister call market post --type raw $(echo 'record { canister_id = principal "'$HELLO'"; price = nat64 1_234_000_000 }' | rebel)

The market’s catalog query now features Bob’s canister:

dfx canister call market catalog

Alice buys Bob’s canister:

dfx identity use alice
dfx canister call market buy --type raw $(echo nat64 0 | rebel)

Alice confirms she now owns the hello canister. She withdraws the rest of her money from her escrow account. She confirms her balance is 1000 - 12.34 minus some transaction fees:

dfx canister info hello
dfx canister call market withdraw
dfx ledger balance --ledger-canister-id $LEDGER

Bob also confirms he was paid:

dfx identity use bob
dfx ledger balance --ledger-canister-id $LEDGER

Further work

To bound memory use, and to keep catalog cheap enough to be a query method, we bound the number of catalog entries. A malicious person could fill the catalog with junk, making this canister useless. To mitigate this, we could require Bob to pay or provide a refundable deposit when listing an entry. We might also expire older entries.

We ought to provide freeze and unfreeze methods, which only the controller of the market canister can call. When frozen, the methods post, buy, and unpost do nothing and return immediately.

Then to upgrade the market canister, we would freeze it first, then call catalog to record the current deals. Then we could either implement an controller-only unpost_all method that restores all listed canisters to their original controllers, or a controller-only append_catalog, in which case we start the upgraded market canister in a frozen state, and add back the existing deals before unfreezing.

An alternative may be a layer of indirection: we split off the tricky parts into a backend canister that is controlled by the frontend market canister.

Instead of storing the original controllers on the canister, we could abuse the controllers field of the canister. For example, we could reorder so the seller’s principal appears first, and flip all the bits of every principal except the market principal. Effectively only the market controls the canister, since this invalidates the other IDs. Also, after a disaster, it’s clear how to recover the original owners of a given canister; the only data lost is the asking price.