Escrow

For all but the simplest transactions between two parties, a useful pattern is for the first party to transfer funds to a trusted third party, which later passes the funds on to the second party after checking various conditions, or refunds the first party if for some reason the deal falls through.

Let’s give the trusted third party a name: Trent. We’ll build a canister that plays the role of trent.

We first focus on the case where the deal falls through. Suppose Alice wishes to participate in a transaction with the help of an escrow canister. She first transfers ICP to an account controlled by Trent, whose ID is derived from Alice’s ID. Next, suppose the deal is off. She asks Trent for a refund via a withdraw method on the escrow canister, which returns all her ICP minus the ledger transaction fee.

Funding

In our design, the first step is an ordinary ledger transfer from Alice to Trent. Only, it’s not to Trent’s default account, but rather the subaccount named after Alice’s account ID. It’s as if the account is labeled with its purpose: "Trent’s account that holds tokens on behalf of Alice".

Thus to compute Trent’s account ID:

  1. Concatenate "\naccount-id" with Trent’s principal ID (without a checksum).

  2. Append Alice’s default account ID.

  3. Compute the SHA224 hash.

  4. Prepend its CRC32 checksum.

Instead of requiring Alice to do this herself, Trent provides a query method to make life easier. Trent’s account_id method returns Trent’s account ID for the caller’s default account (zero subaccount).

$ TRENT=`dfx canister call escrow account_id`
$ transfer $TRENT --amount 123 --memo 0

Refunds

Trent also provides a withdraw update method which Alice can call to back out of a deal.

$ dfx canister call escrow withdraw

The logic for the withdraw refunding method is straightforward:

  1. Look up the account_balance of the caller’s escrow account. Let 'n' be the balance.

  2. If 'n' exceeds the transaction fee, namely 10000 e8s, then transfer 'n - 10000' e8s to the caller’s default account.

However, these are inter-canister calls, so we must express them in terms of callbacks and WebAssembly table functions.

The global phase ensures that at most one withdrawal is in flight, and the globals alice and md hold state required by the callbacks.

We use our Rebel Candid tool to generate hex that we paste together to make Candid messages, thus avoiding the need for a Candid encoder in C.

#include <stddef.h>
#include "sha.h"
#define IMPORT(m,n) __attribute__((import_module(m))) __attribute__((import_name(n)));
#define EXPORT(n) asm(n) __attribute__((visibility("default")))
typedef uint32_t u32;
typedef uint64_t u64;
typedef uint8_t u8;
void reply_append(void*, u32)     IMPORT("ic0", "msg_reply_data_append");
void reply(void)                  IMPORT("ic0", "msg_reply");
u32 caller_size(void)             IMPORT("ic0", "msg_caller_size");
void caller_copy(void*, u32, u32) IMPORT("ic0", "msg_caller_copy");
u32 self_size(void)               IMPORT("ic0", "canister_self_size");
void self_copy(void*, u32, u32)   IMPORT("ic0", "canister_self_copy");
u32 arg_size(void)                IMPORT("ic0", "msg_arg_data_size");
void arg_copy(void*, u32, u32)    IMPORT("ic0", "msg_arg_data_copy");
u32 call_perform()                IMPORT("ic0", "call_perform");
void call_append(void*, u32)      IMPORT("ic0", "call_data_append");
void call_new(void*, u32, void*, u32, u32, u32, u32, u32)
    IMPORT("ic0", "call_new");

void debug_print(void*, u32)      IMPORT("ic0", "debug_print");

u8 unhexit(char c) { return c <= '9' ? c - '0' : 10 + (c <= 'F' ? c - 'A' : c - 'a'); }
void call_append_hex(char *s) {
  u8 single[1];
  while (*s) {
    *single = unhexit(*s++) * 16;
    *single += unhexit(*s++);
    call_append(single, 1);
  }
}

u32 crc(u8 *p, u32 n) {
  static u32 table[256], ready, c;
  if (!ready) {
    for(u32 i = 0; i < 256; i++) {
      c = i;
      for(u32 j = 8; j; j--) c = (c>>1)^((c&1)*0xedb88320);
      table[i] = c;
    }
    ready = 1;
  }
  c = ~0;
  for(u32 i = 0; i < n; i++) c = (c >> 8) ^ table[p[i] ^ (c & 0xff)];
  return ~c;
}

u8 alice[32];
u8 md[SHA224HashSize];
SHA224Context ctx[1];

// Production ledger ID.
// u8 ledger[] = "\x00\x00\x00\x00\x00\x00\x00\x02\x01\x01";
u8 ledger[] = "\x00\x00\x00\x00\x00\x00\x00\x01\x01\x01";
u32 ledger_len = sizeof(ledger) - 1;

void derive_ids() {
  u8 buf[81] = "\naccount-id", *p = buf + 11;

  // Compute caller's default account (zero subaccount).
  u32 n = caller_size();
  caller_copy(p, 0, n);
  p += n;
  for (u32 i = 32; i; i--) *p++ = 0;

  SHA224Reset(ctx);
  SHA224Input(ctx, buf, p - buf);
  SHA224Result(ctx, alice + 4);
  n = crc(alice + 4, SHA224HashSize);
  p = alice;
  for (int i = 3; i >= 0; i--) *p++ = n >> (8*i);

  // Compute this canister's subaccount named after this default account.
  p = buf + 11;
  n = self_size();
  self_copy(p, 0, n);
  p += n;

  for (u32 i = 0; i < 32; i++) *p++ = alice[i];
  SHA224Reset(ctx);
  SHA224Input(ctx, buf, p - buf);
  SHA224Result(ctx, md);
}

u8 hexit(u8 n) { return n < 10 ? n + '0' : n - 10 + 'a'; }

void account_id() EXPORT("canister_query account_id");
void account_id() {
  derive_ids();
  u32 n = crc(md, SHA224HashSize);
  reply_append("DIDL\0\x01\x71\x40", 8);  // Reply is text (0x71) of length 64.
  // Hex dump the account ID (CRC and SHA224 hash).
  u8 single[1];
  for (int i = 3; i >= 0; i--) {
    u8 b = n >> (8*i);
    *single = hexit(b / 16); 
    reply_append(single, 1);
    *single = hexit(b % 16); 
    reply_append(single, 1);
  }
  for (u32 i = 0; i < SHA224HashSize; i++) {
    *single = hexit(md[i] / 16); 
    reply_append(single, 1);
    *single = hexit(md[i] % 16);
    reply_append(single, 1);
  }
  reply();
}

static u32 phase;

void reply_callback(u32);
void reject_callback(u32);

void withdraw() EXPORT("canister_update withdraw");
void withdraw() {
  if (phase) return;
  derive_ids();

  // $ echo 'record { account = blob $ACCOUNT }' | rebel
  // 4449444c026d7b6c01adf9e78a0a000101"$ACCOUNT"
  call_new(ledger, ledger_len, "account_balance", 15, (u32) reply_callback, 1234, (u32) reject_callback, 5678);
  call_append_hex("4449444c026d7b6c01adf9e78a0a000101" "20");
  u32 n = crc(md, SHA224HashSize);
  u8 single[1];
  for (int i = 3; i >= 0; i--) {
    *single = n >> (8*i);
    call_append(single, 1);
  }
  call_append(md, SHA224HashSize);
  u32 err = call_perform();
  if (!err) {
    phase = 1;
  }
  reply_append("DIDL\0\0", 6);
  reply();
}

void reply_callback(u32 whatever) {
  if (phase == 1) {
    if (arg_size() != 22) {
      phase = 0;
      return;
    }
    u8 buf[8];
    arg_copy(buf, 14, 8);  // Extract and decode nat64.
    u64 amount = 0;
    for (u32 i = 7; i < 8; i--) amount = 256*amount + buf[i];
    if (amount <= 10000) {  // Amount must exceed the transaction fee.
      phase = 0;
      return;
    }
    amount -= 10000;
    call_new(ledger, ledger_len, "transfer", 8, (u32) reply_callback, 1234, (u32) reject_callback, 5678);
    // $ echo 'record { memo = nat64 0 ; amount = record { e8s = nat64 $AMT } ; fee = record { e8s = nat64 10000 } ; to = blob $TGT ; from_subaccount = opt blob $SUB }' | rebel
    // 4449444c046d7b6c01e0a9b302786e006c05fbca0100c6fcb60201ba89e5c20478a2de94eb0602d8a38ca80d010103"$TGT"10270000000000000000000000000000"$SUB""$AMT"
    call_append_hex("4449444c046d7b6c01e0a9b302786e006c05fbca0100c6fcb60201ba89e5c20478a2de94eb0602d8a38ca80d010103" "20");
    call_append(alice, 32);
    call_append_hex("10270000000000000000000000000000" "0120");
    call_append(alice, 32);
    u8 single[1];
    for (u32 i = 8; i; i--) {
      *single = amount;
      call_append(single, 1);
      amount >>= 8;
    }
    u32 err = call_perform();
    if (!err) {
      phase = 2;
    } else {
      phase = 0;
    }
  } else {
    debug_print("done", 4);
    phase = 0;
  }
}

void reject_callback(u32 whatever) {
  phase = 0;
}

Once again we enjoy an advantage of using plain C: we can readily reuse existing code. We take Yubico’s implementation of SHA-224. We only need 3 of the files, and Clang just works:

$ git clone https://github.com/Yubico/yubikey-personalization
$ cd yubikey-personalization
$ cp sha.h sha-private.h sha224-256.c ..
$ clang --target=wasm32 -O3 -c sha224-256.c

To build the canister, we also need our homebrew libc from before:

$ wasm-ld --export-dynamic --allow-undefined --no-entry escrow.o libc.o sha224-256.o -o escrow.wasm

Ben Lynn 💡