defmodule ICPAgent do
@moduledoc """
This module provides a client for the ICP protocol.
## Query example
This examples uses the `get_latest_sns_version_pretty` method from the SNS-wasm system canister. It's a publicly available method, so no authentication is needed. We're generating a new wallet ad-hoc and using it to query the
canister.
```elixir
> [versions] = ICPAgent.query("qaa6y-5yaaa-aaaaa-aaafa-cai", DiodeClient.Wallet.new(), "get_latest_sns_version_pretty")
[
[
{"Ledger Index",
"2adc74fe5667f26ea4c4006309d99b1dfa71787aa43a5c168cb08ec725677996"},
{"Governance",
"bd936ef6bb878df87856a0b0c46034a242a88b7f1eeff5439daf6278febca6b7"},
{"Ledger Archive",
"f94cf1db965b7042197e5894fef54f5f413bb2ebc607ff0fb59c9d4dfd3babea"},
{"Swap", "8313ac22d2ef0a0c1290a85b47f235cfa24ca2c96d095b8dbed5502483b9cd18"},
{"Root", "431cb333feb3f762f742b0dea58745633a2a2ca41075e9933183d850b4ddb259"},
{"Ledger",
"25071c2c55ad4571293e00d8e277f442aec7aed88109743ac52df3125209ff45"}
]
]
```
## Call example
Calls and queries both support providing arguments and types in Candid format specification. These are some examples of call structures to give a better understanding of how the types are specified.
```elixir
# Generating a new private key as identity
> wallet = DiodeClient.Wallet.new()
# Call with passing two blobs as an argument
> [200] = ICPAgent.call(canister_id, wallet, "test_blob_input", [:blob, :blob], ["blob_a", "blob_b"])
# Call with passing a record as an argument
> [3] = ICPAgent.call(canister_id, wallet, "test_record_input", [{:record, {:nat32, :nat32}}], [{1, 2}])
# Call with passing a record with named fields as an argument
> [3] = ICPAgent.call(canister_id, wallet, "test_named_record_input", [{:record, %{a: :nat32, b: :nat32}}], [{a: 1, b: 2}])
# Call with passing a vector of records as an argument
> [200] = ICPAgent.call(canister_id, wallet, "test_vec_input", [{:vec, {:record, {:blob, :blob}}}], [[{"blob_a", "blob_b"}]])
```
## Limits
- Only secp256k1 keys are supported.
- Did files are not supported and instead types for a call/query must be manually specified.
"""
alias DiodeClient.Wallet
def default_canister_id do
"bkyz2-fmaaa-aaaaa-qaaaq-cai"
end
def default_host do
# "http://127.0.0.1:4943"
"https://ic0.app"
end
def host do
System.get_env("ICP_DOMAIN", default_host())
end
def status do
fetch("#{host()}/api/v2/status", %{}, method: :get)
end
def domain_separator(name) do
<<byte_size(name), name::binary>>
end
# 5 minutes in nanoseconds
# icp accepts up to 5 minutes ingress expiry into the future.
# we use 2.5 minutes to account for network latency and clock drift placing it in the middle of the range.
@ingress_expiry_delta :timer.minutes(2.5) * 1_000_000
defp sign_query(wallet, query) do
query =
Map.merge(query, %{
"ingress_expiry" => trunc(System.os_time(:nanosecond) + @ingress_expiry_delta),
"sender" => cbor_bytes(wallet_id(wallet))
})
request_id = hash_of_map(query)
sig = wallet_sign(wallet, domain_separator("ic-request") <> request_id)
{request_id,
%{
"content" => utf8_to_list(query),
"sender_pubkey" => cbor_bytes(wallet_der(wallet)),
"sender_sig" => cbor_bytes(sig)
}}
end
def utf8_to_list(map) when is_map(map) and not is_struct(map) do
Enum.map(map, fn {key, value} -> {key, utf8_to_list(value)} end) |> Map.new()
end
def utf8_to_list(list) when is_list(list) do
Enum.map(list, &utf8_to_list/1)
end
def utf8_to_list({:utf8, binary}) when is_binary(binary), do: binary
def utf8_to_list(other), do: other
def call(canister_id, wallet, method, types \\ [], args \\ []) do
{request_id, query} =
sign_query(wallet, %{
"request_type" => "call",
"canister_id" => cbor_bytes(decode_textual(canister_id)),
"method_name" => method,
"arg" => cbor_bytes(Candid.encode_parameters(types, args))
})
fetch("#{host()}/api/v3/canister/#{canister_id}/call", query, method: :post, retry: false)
|> case do
ret = {:error, _err} ->
ret
ret = %{"status" => "replied"} ->
# read_state(canister_id, wallet, [["request_status", cbor_bytes(request_id), "reply"]])
value = cbor_decode!(ret["certificate"].value).value
tree = flatten_tree(value["tree"])
reply = tree["request_status"][request_id]["reply"]
if reply != nil do
{decoded, ""} = Candid.decode_parameters(reply)
decoded
else
tree
end
ret ->
ret
end
end
defp flatten_tree(tree) do
do_flatten_tree(tree)
|> List.wrap()
|> mapify()
end
defp mapify(list) when is_list(list) do
Enum.map(list, fn {key, value} -> {key, mapify(value)} end) |> Map.new()
end
defp mapify({key, value}), do: %{key => mapify(value)}
defp mapify(other), do: other
defp do_flatten_tree([1 | list]),
do: Enum.map(list, &do_flatten_tree/1) |> Enum.reject(&is_nil/1) |> List.flatten()
defp do_flatten_tree([2, key, values]), do: {key.value, do_flatten_tree(values)}
defp do_flatten_tree([3, value]), do: value.value
defp do_flatten_tree([4, _sig]), do: nil
@doc """
This function queries a canister using the ICP query protocol.
# Example:
```elixir
> [versions] = ICPAgent.query("qaa6y-5yaaa-aaaaa-aaafa-cai", DiodeClient.Wallet.new(), "get_latest_sns_version_pretty")
```
"""
def query(canister_id, wallet, method, types \\ [], args \\ []) do
{_request_id, query} =
sign_query(wallet, %{
"request_type" => "query",
"canister_id" => cbor_bytes(decode_textual(canister_id)),
"method_name" => method,
"arg" => cbor_bytes(Candid.encode_parameters(types, args))
})
fetch("#{host()}/api/v2/canister/#{canister_id}/query", query)
|> case do
%{"reply" => %{"arg" => ret}} ->
{ret, ""} = Candid.decode_parameters(ret.value)
ret
err = {:error, _error} ->
err
end
end
def read_state(canister_id, wallet, paths) do
{_request_id, query} =
sign_query(wallet, %{
"request_type" => "read_state",
"paths" => paths
})
%{"reply" => %{"arg" => ret}} =
fetch("#{host()}/api/v2/canister/#{canister_id}/read_state", query)
{ret, ""} = Candid.decode_parameters(ret.value)
ret
end
defp cbor_decode!(payload, metadata \\ nil) do
case CBOR.decode(payload) do
{:ok, decoded, ""} -> decoded
other -> raise "Failed to decode CBOR: #{inspect({other, metadata})}}"
end
end
defp fetch(host, opayload, opts \\ []) do
now = System.os_time(:millisecond)
payload = CBOR.encode(opayload)
cbor_decode!(payload)
timeout = 15_000
method = opts[:method] || :post
retry = opts[:retry] || :safe_transient
opts =
[
url: host,
method: method,
retry: retry,
receive_timeout: timeout,
connect_options: [timeout: timeout],
headers: [content_type: "application/cbor"]
]
case method do
:get -> Req.new(opts)
:post -> Req.new([body: payload] ++ opts)
end
|> Req.request()
|> process_response(now, opayload["content"]["method_name"] || "", payload, host)
end
defp process_response({:ok, ret}, now, method, payload, host) do
p1 = System.os_time(:millisecond)
if print_requests?() do
IO.puts(
"POST #{method} #{String.replace_prefix(host, host(), "")} (#{byte_size(payload)} bytes request)"
)
# if method == :post do
# IO.puts(">> #{inspect(opayload)}")
# end
end
p2 = System.os_time(:millisecond)
if print_requests?() do
IO.puts(
"POST latency: #{p2 - now}ms http: #{p1 - now}ms (#{byte_size(ret.body)} bytes response)"
)
IO.puts("")
end
if ret.status >= 300 or ret.status < 200 or String.starts_with?(ret.body, "error:") or
String.starts_with?(ret.body, "Message did not complete") or
ret.headers["content-type"] == ["text/plain; charset=utf-8"] do
{:error, ret.body}
else
cbor_decode!(ret.body, ret).value
end
end
defp process_response(other, _now, _method, _payload, _host) do
other
end
def print_requests? do
System.get_env("ICP_PRINT_REQUESTS", "false") == "true"
end
@doc """
Implementation of the ICP hash function. It is in the ICP docs usually referred to as `H()`.
https://internetcomputer.org/docs/current/references/ic-interface-spec
"""
def h([]), do: :crypto.hash(:sha256, "")
def h(list) when is_list(list), do: :crypto.hash(:sha256, Enum.map_join(list, &h/1))
def h(number) when is_integer(number), do: h(LEB128.encode_unsigned(number))
def h(%CBOR.Tag{tag: :bytes, value: data}), do: h(data)
def h({:utf8, data}) when is_binary(data), do: h(data)
def h(data) when is_binary(data), do: :crypto.hash(:sha256, data)
@doc """
Implementation of the ICP hash function for a map. It is in the ICP docs usually referred to as `hash_of_map`.
https://internetcomputer.org/docs/current/references/ic-interface-spec#request-id
"""
def hash_of_map(map) do
map
|> Enum.map(fn {key, value} ->
h(key) <> h(value)
end)
|> Enum.sort()
|> Enum.join("")
|> h()
end
@doc """
This function converts a DiodeClient.Wallet.t() into a binary representation of the public ICP Principal identifier.
https://internetcomputer.org/docs/current/references/ic-interface-spec#id-classes
"""
def wallet_id(wallet) do
:crypto.hash(:sha224, wallet_der(wallet)) <> <<2>>
end
@doc """
This function computes the CRC32 checksum of a binary.
"""
def crc32(data) do
<<:erlang.crc32(data)::size(32)>>
end
@doc """
This function converts a DiodeClient.Wallet.t() into a textual representation of the public ICP Principal identifier.
"""
def wallet_textual(wallet) do
wallet_id(wallet)
|> encode_textual()
end
def encode_textual(id) do
Base.encode32(crc32(id) <> id, case: :lower, padding: false)
|> String.to_charlist()
|> Enum.chunk_every(5)
|> Enum.join("-")
end
@doc """
This function signs a binary with a DiodeClient.Wallet.t() using the secp256k1 algorithm and the ICP signing scheme.
"""
def wallet_sign(wallet, data) do
<<_recovery, rest::binary>> = DiodeClient.Secp256k1.sign(Wallet.privkey!(wallet), data, :sha)
rest
end
@doc """
This function converts a DiodeClient.Wallet.t() into a DER encoded binary representation of the public key.
The DER encoded binary representation is the canonical form as used by the ICP in various protocol interactions.
"""
def wallet_der(wallet) do
public = Wallet.pubkey_long!(wallet)
term =
{:OTPSubjectPublicKeyInfo,
{:PublicKeyAlgorithm, {1, 2, 840, 10_045, 2, 1}, {:namedCurve, {1, 3, 132, 0, 10}}},
public}
:public_key.pkix_encode(:OTPSubjectPublicKeyInfo, term, :otp)
end
def wallet_private_pem(wallet) do
privkey = Wallet.privkey!(wallet)
pubkey = Wallet.pubkey_long!(wallet)
der =
:public_key.der_encode(
:ECPrivateKey,
{:ECPrivateKey, 1, privkey, {:namedCurve, {1, 3, 132, 0, 10}}, pubkey, :asn1_NOVALUE}
)
:public_key.pem_encode([{:ECPrivateKey, der, :not_encrypted}])
end
@doc """
This function converts a PEM encoded binary representation of a Secp256k1 curve private key into a DiodeClient.Wallet.t().
"""
def wallet_from_pem(pem) do
[{:ECPrivateKey, der, _}] = :public_key.pem_decode(pem)
{:ECPrivateKey, 1, privkey, {:namedCurve, {1, 3, 132, 0, 10}}, pubkey, :asn1_NOVALUE} =
:public_key.der_decode(:ECPrivateKey, der)
wallet = Wallet.from_privkey(privkey)
^pubkey = Wallet.pubkey_long!(wallet)
wallet
end
defp cbor_bytes(data) do
%CBOR.Tag{tag: :bytes, value: data}
end
@doc """
This function decodes a textual representation of an ICP Principal identifier into a binary representation.
# Example
```
iex> ICPAgent.decode_textual("bkyz2-fmaaa-aaaaa-qaaaq-cai")
<<128, 0, 0, 0, 0, 16, 0, 1, 1, 1>>
```
"""
def decode_textual(canister_id) do
<<_crc32::binary-size(4), canister_bin_id::binary>> =
String.replace(canister_id, "-", "") |> Base.decode32!(case: :lower, padding: false)
canister_bin_id
end
end