defmodule OnFlow do
import __MODULE__.Channel, only: [get_channel: 0]
import __MODULE__.{Util, Transaction}
alias __MODULE__.{Credentials, JSONCDC, TransactionResponse}
@type account() :: OnFlow.Entities.Account.t()
@type address() :: binary()
@type error() :: {:error, GRPC.RPCError.t()}
@type hex_string() :: String.t()
@type transaction_result() :: {:ok | :error, TransactionResponse.t()} | {:error, :timeout}
@doc """
Creates a Flow account. Note that an existing account must be passed in as the
first argument, since internally this is executed as a transaction on the
existing account.
Available options are:
* `:gas_limit` - the maximum amount of gas to use for the transaction.
Defaults to 100.
On success, it returns `{:ok, address}`, where `address` is a hex-encoded
representation of the address.
On failure, it returns `{:error, response}` or `{:error, :timeout}`.
"""
@spec create_account(Credentials.t(), hex_string(), keyword()) ::
{:ok, hex_string()} | transaction_result()
def create_account(%Credentials{} = credentials, public_key, opts \\ []) do
arguments = [
%{type: "String", value: public_key},
%{type: "Dictionary", value: []}
]
gas_limit =
case Keyword.fetch(opts, :gas_limit) do
{:ok, gas_limit} -> gas_limit
:error -> 100
end
send_transaction(render_create_account(), credentials, gas_limit,
arguments: arguments,
authorizers: credentials,
payer: credentials
)
|> case do
{:ok, %{result: %OnFlow.Access.TransactionResultResponse{events: events}}} ->
address =
Enum.find_value(events, fn
%{"id" => "flow.AccountCreated", "fields" => %{"address" => address}} -> address
_ -> false
end)
|> trim_0x()
{:ok, address}
error ->
error
end
end
@doc """
Deploys a contract to an account. This just takes in existing account
credentials, the name of the contract, and the contract code. Internally, this
is just a single-signer, single-authorizer transaction.
Options:
* `:gas_limit` - the maximum amount of gas to use for the transaction.
Defaults to 100.
* `:update` - either `true` or `false` to update a previously deployed
contract with the same name.
"""
@spec deploy_contract(Credentials.t(), String.t(), String.t(), keyword()) ::
transaction_result()
def deploy_contract(%Credentials{} = credentials, name, contract, opts \\ []) do
arguments = [
%{"type" => "String", "value" => name},
%{"type" => "String", "value" => encode16(contract)}
]
gas_limit = Keyword.get(opts, :gas_limit, 100)
case Keyword.fetch(opts, :update) do
{:ok, update} when is_boolean(update) -> update
:error -> false
end
|> case do
true -> render_update_account_contract()
false -> render_add_account_contract()
end
|> send_transaction(credentials, gas_limit,
arguments: arguments,
authorizers: credentials,
payer: credentials
)
end
@doc """
Sends a transaction. Options:
* `:args` - the list of objects that will be sent along with the
transaction. This must be an Elixir list that can be encoded to JSON.
* `:authorizers` - a list of authorizing `%Credentials{}` structs to
authorize the transaction.
* `:payer` - a hex-encoded address or `%Credentials{}` struct that will pay
for the transaction.
* `:wait_until_sealed` - either `true` or `false`. Note that if the
transaction is not sealed after 30 seconds, this will return `{:error,
:timeout}`. Defaults to `true`.
"""
@spec send_transaction(
String.t(),
[Credentials.t()] | Credentials.t(),
non_neg_integer(),
keyword()
) :: transaction_result()
def send_transaction(script, signers, gas_limit, opts \\ []) do
if not is_integer(gas_limit) or gas_limit < 0 do
raise "Invalid gas limit. You must specify a non-negative integer. Received: #{inspect(gas_limit)}"
end
signers = to_list(signers)
authorizers = to_list(Keyword.get(opts, :authorizers, []))
payer =
case Keyword.get(opts, :payer) do
%Credentials{address: address} -> address
payer when is_binary(payer) -> payer
end
|> decode16()
if signers == [] do
raise "You must provide at least one signer"
end
if payer not in Enum.map(authorizers, &decode16(&1.address)) do
raise "Payer address #{inspect(payer)} not found in the list of authorizers."
end
# Set the proposal key. This is just the account that lends its sequence
# number to the transaction.
{:ok, %{keys: [proposer_key | _]} = proposer} = get_account(hd(signers).address)
proposal_key =
OnFlow.Entities.Transaction.ProposalKey.new(%{
address: proposer.address,
key_id: proposer_key.index,
sequence_number: proposer_key.sequence_number
})
authorizer_addresses = for a <- authorizers, do: decode16(a.address)
args = Keyword.get(opts, :arguments, [])
wait_until_sealed? =
case Keyword.fetch(opts, :wait_until_sealed) do
{:ok, wait_until_sealed} when is_boolean(wait_until_sealed) -> wait_until_sealed
_ -> true
end
OnFlow.Entities.Transaction.new(%{
arguments: parse_args(args),
authorizers: authorizer_addresses,
gas_limit: gas_limit,
payer: payer,
proposal_key: proposal_key,
reference_block_id: get_latest_block_id(),
script: script
})
|> maybe_sign_payload(signers)
|> sign_envelope(signers)
|> do_send_transaction(wait_until_sealed?)
end
defp to_list(list) when is_list(list), do: list
defp to_list(item), do: [item]
defp maybe_sign_payload(transaction, signers) when is_list(signers) do
# Special case: if an account is both the payer and either a proposer or
# authorizer, it is only required to sign the envelope.
Enum.reduce(signers, transaction, fn signer, transaction ->
decoded_address = decode16(signer.address)
payer? = decoded_address == transaction.payer
authorizer? = decoded_address in transaction.authorizers
proposer? = decoded_address == transaction.proposal_key.address
if payer? and (authorizer? or proposer?) do
transaction
else
do_sign_payload(transaction, signer)
end
end)
end
defp do_sign_payload(transaction, signer) do
address = decode16(signer.address)
private_key = decode16(signer.private_key)
rlp = payload_canonical_form(transaction)
message = domain_tag() <> rlp
signer_signature = build_signature(address, private_key, message)
signatures = transaction.payload_signatures ++ [signer_signature]
%{transaction | payload_signatures: signatures}
end
defp sign_envelope(transaction, signers) when is_list(signers) do
Enum.reduce(signers, transaction, fn signer, transaction ->
sign_envelope(transaction, signer)
end)
end
defp sign_envelope(transaction, signer) do
address = decode16(signer.address)
private_key = decode16(signer.private_key)
rlp = envelope_canonical_form(transaction)
message = domain_tag() <> rlp
signer_signature = build_signature(address, private_key, message)
signatures = transaction.envelope_signatures ++ [signer_signature]
%{transaction | envelope_signatures: signatures}
end
@doc false
defp do_send_transaction(transaction, wait_until_sealed?) do
request = OnFlow.Access.SendTransactionRequest.new(%{transaction: transaction})
response = OnFlow.Access.AccessAPI.Stub.send_transaction(get_channel(), request)
with {:ok, %{id: id} = transaction} <- response do
if wait_until_sealed? do
{status, result} = do_get_sealed_transaction_result(encode16(id))
{status, %TransactionResponse{transaction: transaction, result: result}}
else
%TransactionResponse{transaction: transaction, result: nil}
end
else
_ -> response
end
end
defp domain_tag do
pad("FLOW-V0.0-transaction", 32, :right)
end
@doc """
Returns a binary of the latest block ID. This is typically used as a reference
ID when sending transactions to the network.
"""
def get_latest_block_id do
{:ok, %{block: %{id: latest_block_id}}} =
OnFlow.Access.AccessAPI.Stub.get_latest_block(
get_channel(),
OnFlow.Access.GetLatestBlockRequest.new()
)
latest_block_id
end
@doc """
Fetches the transaction result for a given transaction ID.
"""
def get_transaction_result(id) do
req = OnFlow.Access.GetTransactionRequest.new(%{id: decode16(id)})
OnFlow.Access.AccessAPI.Stub.get_transaction_result(get_channel(), req)
end
defp do_get_sealed_transaction_result(id, num_attempts \\ 0)
defp do_get_sealed_transaction_result(_id, n) when n > 30 do
{:error, :timeout}
end
defp do_get_sealed_transaction_result(id, num_attempts) do
case get_transaction_result(id) do
{:ok, %OnFlow.Access.TransactionResultResponse{status: :SEALED} = response} ->
events =
Enum.map(response.events, fn event ->
{:event, event} = JSONCDC.decode!(event.payload)
event
end)
response = %{response | events: events}
if not empty?(response.error_message) do
{:error, response}
else
{:ok, response}
end
{:ok, %OnFlow.Access.TransactionResultResponse{status: _}} ->
:timer.sleep(1000)
do_get_sealed_transaction_result(id, num_attempts + 1)
error ->
error
end
end
@doc """
Executes a script on the Flow network to show account data.
"""
@spec get_account(address()) :: {:ok, account()} | error()
def get_account(address) do
address = decode16(address)
req = OnFlow.Access.GetAccountRequest.new(%{address: address})
case OnFlow.Access.AccessAPI.Stub.get_account(get_channel(), req) do
{:ok, %{account: account}} -> {:ok, account}
error -> error
end
end
def execute_script(code, args \\ []) do
request =
OnFlow.Access.ExecuteScriptAtLatestBlockRequest.new(%{
arguments: parse_args(args),
script: code
})
get_channel()
|> OnFlow.Access.AccessAPI.Stub.execute_script_at_latest_block(request)
|> case do
{:ok, %OnFlow.Access.ExecuteScriptResponse{value: response}} ->
{:ok, Jason.decode!(response)}
{:error, _} = result ->
result
end
end
defp parse_args(args) do
for arg <- args, do: Jason.encode!(arg)
end
require EEx
for template <- ~w(create_account add_account_contract update_account_contract)a do
EEx.function_from_file(
:defp,
:"render_#{template}",
"lib/on_flow/templates/#{template}.cdc.eex",
[]
)
end
end