Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: specify textual encoding #55

Merged
merged 11 commits into from Sep 29, 2022
62 changes: 62 additions & 0 deletions README.md
Expand Up @@ -217,6 +217,68 @@ variant { Err = variant { BadBurn = record { min_burn_amount = ... } } }

The minting account is also the receiver of the fees burnt in regular transfers.

## Textual representation of accounts

We specify a _canonical textual format_ that all applications should use to display ICRC-1 accounts.
This format relies on the textual encoding of principals specified in the [Internet Computer Interface Specification](https://internetcomputer.org/docs/current/references/ic-interface-spec/#textual-ids), referred to as `Principal.toText` and `Principal.fromText` below.
The format has the following desirable properties:

1. A textual encoding of any non-reserved principal is a valid textual encoding of the default account of that principal on the ledger.
2. The decoding function is injective (i.e., different valid encodings correspond to different accounts).
This property enables applications to use text representation as a key.
3. A typo in the textual encoding invalidates it with a high probability.

### Encoding

Applications SHOULD encode accounts as follows:

1. The encoding of the default account (the subaccount is null or a blob with 32 zeros) is the encoding of the owner principal.
2. The encoding of accounts with a non-default subaccount is the textual principal encoding of the concatenation of the owner principal bytes, the subaccount bytes with the leading zeros omitted, the length of the subaccount without the leading zeros (a single byte), and an extra byte `7F`<sub>16</sub>.

In pseudocode:

```sml
encodeAccount({ owner; subaccount }) = case subaccount of
| None ⇒ Principal.toText(owner)
| Some([32; 0]) ⇒ Principal.toText(owner)
| Some(bytes) ⇒ Principal.toText(owner · shrink(bytes) · [|shrink(bytes)|, 0x7f])

shrink(bytes) = case bytes of
| 0x00 :: rest ⇒ shrink(rest)
| bytes ⇒ bytes
```

### Decoding

Applications SHOULD decode textual representation as follows:

1. Decode the text as if it was a principal into `raw_bytes`, ignoring the principal length check (some decoders allow the principal to be at most 29 bytes long).
2. If `raw_bytes` do not end with byte `7F`<sub>16</sub>, return an account with `raw_bytes` as the owner and an empty subaccount.
3. If `raw_bytes` end with `7F`<sub>16</sub>:
1. Drop the last `7F`<sub>16</sub> byte.
2. Read the last byte `N` and drop it. If `N > 32` or `N = 0`, raise an error.
3. Take the last N bytes and strip them from the input.
If the first byte in the stripped sequence is zero, raise an error.
Prepend the bytes with (32 - N) zeros on the left to get a 32-byte subaccount.
4. Return an account with the owner being the rest of the input sequence as the owner and the subaccount being the byte array constructed in the previous step.

In pseudocode:

```sml
decodeAccount(text) = case Principal.fromText(text) of
| (prefix · [n, 0x7f]) where Blob.size(prefix) < n ⇒ raise Error
| (prefix · [n, 0x7f]) where n > 32 orelse n = 0 ⇒ raise Error
| (prefix · suffix · [n, 0x7f]) where Blob.size(suffix) = n ⇒
if suffix[0] = 0
then raise Error
else { owner = Principal.fromBlob(prefix); subaccount = Some(expand(suffix)) }
| raw_bytes ⇒ { owner = Principal.fromBlob(raw_bytes); subaccount = None }

expand(bytes) = if Blob.size(bytes) < 32
then expand(0x00 :: bytes)
else bytes
```

<!--
```candid ICRC-1.did +=
<<<Type definitions>>>
Expand Down
91 changes: 91 additions & 0 deletions ref/Account.mo
@@ -0,0 +1,91 @@
import Array "mo:base/Array";
import Blob "mo:base/Blob";
import Buffer "mo:base/Buffer";
import Option "mo:base/Option";
import Result "mo:base/Result";
import Nat8 "mo:base/Nat8";
import Principal "mo:base/Principal";

module {
public type Account = { owner : Principal; subaccount : ?Blob };
public type DecodeError = {
// Subaccount length is invalid.
#bad_length;
// The subaccount encoding is not canonical.
#not_canonical;
};

public func toText(acc : Account) : Text {
switch (acc.subaccount) {
case (null) { Principal.toText(acc.owner) };
case (?blob) {
assert(blob.size() == 32);
var zeroCount = 0;

label l for (byte in blob.vals()) {
if (byte == 0) { zeroCount += 1; }
else break l;
};

if (zeroCount == 32) {
Principal.toText(acc.owner)
} else {
let principalBytes = Principal.toBlob(acc.owner);
let buf = Buffer.Buffer<Nat8>(principalBytes.size() + blob.size() - zeroCount + 2);

for (b in principalBytes.vals()) {
buf.add(b);
};

var j = 0;
label l for (b in blob.vals()) {
j += 1;
if (j <= zeroCount) {
continue l;
};
buf.add(b);
};

buf.add(Nat8.fromNat(32 - zeroCount));
buf.add(Nat8.fromNat(0x7f));

Principal.toText(Principal.fromBlob(Blob.fromArray(buf.toArray())))
}
}
}
};

public func fromText(text : Text) : Result.Result<Account, DecodeError> {
let principal = Principal.fromText(text);
let bytes = Blob.toArray(Principal.toBlob(principal));

if (bytes.size() == 0 or bytes[bytes.size() - 1] != Nat8.fromNat(0x7f)) {
return #ok({ owner = principal; subaccount = null });
};

if (bytes.size() == 1) {
return #err(#bad_length);
};

let n = Nat8.toNat(bytes[bytes.size() - 2]);
if (n == 0) {
return #err(#not_canonical);
};
if (n > 32 or bytes.size() < n + 2 ) {
return #err(#bad_length);
};
if (bytes[bytes.size() - n - 2] == Nat8.fromNat(0)) {
return #err(#not_canonical);
};

let zeroCount = 32 - n;
let subaccount = Blob.fromArray(Array.tabulate(32, func (i: Nat) : Nat8 {
if (i < zeroCount) { Nat8.fromNat(0) }
else { bytes[bytes.size() - n - 2 + i - zeroCount] }
}));

let owner = Blob.fromArray(Array.tabulate(bytes.size() - n - 2, func (i : Nat) : Nat8 { bytes[i] }));

#ok({ owner = Principal.fromBlob(owner); subaccount = ?subaccount })
}
}
67 changes: 67 additions & 0 deletions ref/AccountTest.mo
@@ -0,0 +1,67 @@
import Account "mo:account/Account";
import Array "mo:base/Array";
import Blob "mo:base/Blob";
import Debug "mo:base/Debug";
import Nat8 "mo:base/Nat8";
import Principal "mo:base/Principal";
import Prelude "mo:base/Prelude";
import Result "mo:base/Result";
import Text "mo:base/Text";
import Iter "mo:base/Iter";

func hexDigit(b : Nat8) : Nat8 {
switch (b) {
case (48 or 49 or 50 or 51 or 52 or 53 or 54 or 55 or 56 or 57) { b - 48 };
case (65 or 66 or 67 or 68 or 69 or 70) { 10 + (b - 65) };
case (97 or 98 or 99 or 100 or 101 or 102) { 10 + (b - 97) };
case _ { Prelude.nyi() };
}
};

func hexDecode(t : Text) : Blob {
assert (t.size() % 2 == 0);
let n = t.size() / 2;
let h = Blob.toArray(Text.encodeUtf8(t));
var b : [var Nat8] = Array.init(n, Nat8.fromNat(0));
for (i in Iter.range(0, n - 1)) {
b[i] := hexDigit(h[2 * i]) << 4 | hexDigit(h[2 * i + 1]);
};
Blob.fromArrayMut(b)
};

func hexToPrincipal(hex : Text) : Text {
Principal.toText(Principal.fromBlob(hexDecode(hex)))
};

func checkDecode(text : Text, expected : Result.Result<Account.Account, Account.DecodeError>) {
let result = Account.fromText(text);
if (result != expected) {
Debug.print("expected text " # text # " to decode as " # debug_show expected # ", got: " # debug_show result);
};
assert(result == expected);
};

func checkEncode(acc : Account.Account, expected : Text) {
let actual = Account.toText(acc);
if (actual != expected) {
Debug.print("expected account " # debug_show acc # " to be encoded as " # expected # ", got: " # actual);
};
assert(actual == expected);
};

checkEncode({ owner = Principal.fromText("aaaaa-aa"); subaccount = null }, "aaaaa-aa");
checkEncode({ owner = Principal.fromText("aaaaa-aa"); subaccount = ?hexDecode("0000000000000000000000000000000000000000000000000000000000000000") }, "aaaaa-aa");
checkEncode({ owner = Principal.fromText("2vxsx-fae"); subaccount = null }, "2vxsx-fae");
checkEncode({ owner = Principal.fromText("2vxsx-fae"); subaccount = ?hexDecode("0000000000000000000000000000000000000000000000000000000000000000") }, "2vxsx-fae");
checkEncode({ owner = Principal.fromText("2vxsx-fae"); subaccount = ?hexDecode("0000000000000000000000000000000000000000000000000000000000000001") }, hexToPrincipal("0401017f"));
checkEncode({ owner = Principal.fromText("2vxsx-fae"); subaccount = ?hexDecode("00000000000000000000ffffffffffffffffffffffffffffffffffffffffffff") }, hexToPrincipal("04ffffffffffffffffffffffffffffffffffffffffffff167f"));

checkDecode(hexToPrincipal(""), #ok({ owner = Principal.fromText("aaaaa-aa"); subaccount = null }));
checkDecode(hexToPrincipal("04"), #ok({ owner = Principal.fromText("2vxsx-fae"); subaccount = null }));
checkDecode(hexToPrincipal("7f"), #err(#bad_length));
checkDecode(hexToPrincipal("007f"), #err(#not_canonical));
checkDecode(hexToPrincipal("0401017f"), #ok({ owner = Principal.fromText("2vxsx-fae"); subaccount = ?hexDecode("0000000000000000000000000000000000000000000000000000000000000001") }));
checkDecode(hexToPrincipal("0401027f"), #ok({ owner = Principal.fromText("aaaaa-aa"); subaccount = ?hexDecode("0000000000000000000000000000000000000000000000000000000000000401") }));
checkDecode(hexToPrincipal("0401037f"), #err(#bad_length));
checkDecode(hexToPrincipal("0400017f"), #err(#not_canonical));
checkDecode(hexToPrincipal("040101010101010101010101010101010101010101010101010101010101010101207f"), #ok({ owner = Principal.fromText("2vxsx-fae"); subaccount = ?hexDecode("0101010101010101010101010101010101010101010101010101010101010101") }));
17 changes: 16 additions & 1 deletion ref/BUILD.bazel
@@ -1,11 +1,26 @@
load("@rules_motoko//motoko:defs.bzl", "motoko_binary", "motoko_library")
load("@rules_motoko//motoko:defs.bzl", "motoko_binary", "motoko_library", "motoko_test")
load("//bazel:didc_test.bzl", "didc_subtype_test", "motoko_actor_did_file")

motoko_library(
name = "base",
srcs = ["@motoko_base//:sources"],
)

motoko_library(
name = "account",
srcs = ["Account.mo"],
deps = [":base"],
)

motoko_test(
name = "account_test",
entry = "AccountTest.mo",
deps = [
":account",
":base",
],
)

motoko_binary(
name = "icrc1_ref",
entry = "ICRC1.mo",
Expand Down