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; }
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 apost
, 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.
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
:
Testing
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 {"canisters": {"ledger":{"type":"custom","candid":"nothing","wasm":"ledger.wasm"}, "market":{"type":"custom","candid":"market.did","wasm":"market.wasm"}, "hello":{"type":"custom","candid":"nothing","wasm":"hello.wasm"} }} 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.