Current section
Files
Jump to
Current section
Files
lib/sutra/cardano/blueprint.ex
defmodule Sutra.Cardano.Blueprint do
@moduledoc """
Blueprint parser for encoding and decoding Plutus data based on CIP-57 blueprint schemas.
This module provides functionality to:
- Encode Elixir values into Plutus data based on schema definitions
- Decode Plutus data into Elixir values based on schema definitions
## Schema Types Supported
- **bytes**: Binary data (hex-encoded strings or raw binaries)
- **integer**: Integers
- **list**: Lists with homogeneous items
- **constructor**: Tagged constructors (enums/records)
- **map**: Key-value pairs
- **$ref**: References to other definitions
## Example
# Load a blueprint
{:ok, blueprint_json} = File.read("plutus.json")
{:ok, blueprint} = Jason.decode(blueprint_json)
# Get a schema definition
# Encode a value
value = %{constructor: "CreateLend", fields: %{}}
{:ok, plutus_data} = Blueprint.encode(value, schema, blueprint["definitions"])
# Decode plutus data back
{:ok, decoded} = Blueprint.decode(plutus_data, schema, blueprint["definitions"])
"""
alias Sutra.Data.Plutus
alias Sutra.Data.Plutus.{Constr, PList}
alias Sutra.Utils
@type schema :: map()
@type definitions :: map()
@type encode_error :: {:error, {:encode_error, String.t()}}
@type decode_error :: {:error, {:decode_error, String.t()}}
# ============================================================================
# Public API
# ============================================================================
@doc """
Encodes an Elixir value into Plutus data based on the provided schema.
## Parameters
- `value` - The Elixir value to encode
- `schema` - The schema definition (map with schema type info or $ref)
- `definitions` - Optional map of all definitions from the blueprint (required for $ref)
## Returns
- `{:ok, plutus_data}` - Successfully encoded Plutus data
- `{:error, reason}` - Encoding failed
## Examples
# Encode an integer
iex> Blueprint.encode(42, %{"dataType" => "integer"})
{:ok, 42}
# Encode bytes
iex> Blueprint.encode("deadbeef", %{"dataType" => "bytes"})
{:ok, <<222, 173, 190, 239>>}
# Encode a constructor
iex> schema = %{
...> "anyOf" => [
...> %{"title" => "Foo", "dataType" => "constructor", "index" => 0, "fields" => []}
...> ]
...> }
iex> Blueprint.encode(%{constructor: "Foo"}, schema)
{:ok, %Constr{index: 0, fields: []}}
"""
@spec encode(any(), schema(), definitions()) :: {:ok, Plutus.t()} | encode_error()
def encode(value, schema, definitions \\ %{})
def encode(value, %{"$ref" => ref}, definitions) do
case resolve_ref(ref, definitions) do
{:ok, resolved_schema} -> encode(value, resolved_schema, definitions)
error -> error
end
end
# Handle "anyOf" schemas (constructors/enums)
def encode(value, %{"anyOf" => variants}, definitions) do
encode_constructor(value, variants, definitions)
end
# Handle primitive types
def encode(value, %{"dataType" => "bytes"} = schema, _definitions) do
encode_bytes(value, schema)
end
# ... (leaving integer encode as is or modify context if contiguous)
def encode(value, %{"dataType" => "integer"}, _definitions) do
encode_integer(value)
end
# Handle lists with single item schema (homogeneous list)
def encode(value, %{"dataType" => "list", "items" => items}, definitions) when is_map(items) do
encode_list(value, items, definitions)
end
# Handle tuples (list with array of item schemas)
def encode(value, %{"dataType" => "list", "items" => items}, definitions)
when is_list(items) do
encode_tuple(value, items, definitions)
end
# Handle map type
def encode(
value,
%{"dataType" => "map", "keys" => keys_schema, "values" => values_schema},
definitions
) do
encode_map(value, keys_schema, values_schema, definitions)
end
# Handle single constructor schema (not wrapped in anyOf)
def encode(value, %{"dataType" => "constructor"} = schema, definitions) do
# Wrap in anyOf and delegate to constructor encoding
encode_constructor(value, [schema], definitions)
end
# Handle "Data" type (any plutus data - pass through)
def encode(value, %{"title" => "Data"}, _definitions) do
{:ok, value}
end
# Handle module reference - delegate to module's schema/functions
def encode(value, %{"$module" => module}, _definitions) when is_atom(module) do
Code.ensure_loaded(module)
cond do
# If value is already the right struct and module has to_plutus, use it
is_struct(value) and function_exported?(value.__struct__, :to_plutus, 1) ->
{:ok, value.__struct__.to_plutus(value)}
# If module has __schema__, use Blueprint with that schema
function_exported?(module, :__schema__, 0) ->
encode(value, module.__schema__())
# If module has to_plutus, use it directly
function_exported?(module, :to_plutus, 1) ->
{:ok, module.to_plutus(value)}
true ->
{:error,
{:encode_error, "Module #{inspect(module)} does not have __schema__/0 or to_plutus/1"}}
end
end
# Handle empty schema (pass through as raw data)
def encode(value, schema, _definitions) when schema == %{} do
{:ok, value}
end
def encode(_value, schema, _definitions) do
{:error, {:encode_error, "Unsupported schema type: #{inspect(schema)}"}}
end
@doc """
Decodes Plutus data into an Elixir value based on the provided schema.
## Parameters
- `plutus_data` - The Plutus data to decode
- `schema` - The schema definition (map with schema type info or $ref)
- `definitions` - Optional map of all definitions from the blueprint (required for $ref)
## Returns
- `{:ok, value}` - Successfully decoded value
- `{:error, reason}` - Decoding failed
## Examples
# Decode an integer
iex> Blueprint.decode(42, %{"dataType" => "integer"})
{:ok, 42}
# Decode bytes
iex> Blueprint.decode(<<222, 173, 190, 239>>, %{"dataType" => "bytes"})
{:ok, <<222, 173, 190, 239>>}
# Decode a constructor
iex> schema = %{
...> "anyOf" => [
...> %{"title" => "Foo", "dataType" => "constructor", "index" => 0, "fields" => []}
...> ]
...> }
iex> Blueprint.decode(%Constr{index: 0, fields: []}, schema)
{:ok, %{constructor: "Foo", fields: %{}}}
"""
@spec decode(Plutus.t(), schema(), definitions()) :: {:ok, any()} | decode_error()
def decode(plutus_data, schema, definitions \\ %{})
def decode(plutus_data, %{"$ref" => ref}, definitions) do
case resolve_ref(ref, definitions) do
{:ok, resolved_schema} -> decode(plutus_data, resolved_schema, definitions)
error -> error
end
end
# Handle "anyOf" schemas (constructors/enums)
def decode(plutus_data, %{"anyOf" => variants}, definitions) do
decode_constructor(plutus_data, variants, definitions)
end
# Handle primitive types
def decode(plutus_data, %{"dataType" => "bytes"} = schema, _definitions) do
decode_bytes(plutus_data, schema)
end
def decode(plutus_data, %{"dataType" => "integer"}, _definitions) do
decode_integer(plutus_data)
end
# Handle lists with single item schema (homogeneous list)
def decode(plutus_data, %{"dataType" => "list", "items" => items}, definitions)
when is_map(items) do
decode_list(plutus_data, items, definitions)
end
# Handle tuples (list with array of item schemas)
def decode(plutus_data, %{"dataType" => "list", "items" => items}, definitions)
when is_list(items) do
decode_tuple(plutus_data, items, definitions)
end
# Handle map type
def decode(
plutus_data,
%{"dataType" => "map", "keys" => keys_schema, "values" => values_schema},
definitions
) do
decode_map(plutus_data, keys_schema, values_schema, definitions)
end
# Handle single constructor schema (not wrapped in anyOf)
def decode(plutus_data, %{"dataType" => "constructor"} = schema, definitions) do
# Wrap in anyOf and delegate to constructor decoding
decode_constructor(plutus_data, [schema], definitions)
end
# Handle "Data" type (any plutus data - pass through)
def decode(plutus_data, %{"title" => "Data"}, _definitions) do
{:ok, plutus_data}
end
# Handle module reference - delegate to module's schema/functions
def decode(plutus_data, %{"$module" => module}, _definitions) when is_atom(module) do
Code.ensure_loaded(module)
cond do
# If module has from_plutus, use it directly (preferred as it returns struct)
function_exported?(module, :from_plutus, 1) ->
module.from_plutus(plutus_data)
# If module has __schema__, use Blueprint with that schema
function_exported?(module, :__schema__, 0) ->
decode(plutus_data, module.__schema__())
true ->
{:error,
{:decode_error, "Module #{inspect(module)} does not have __schema__/0 or from_plutus/1"}}
end
end
# Handle empty schema (pass through as raw data)
def decode(plutus_data, schema, _definitions) when schema == %{} do
{:ok, plutus_data}
end
def decode(_plutus_data, schema, _definitions) do
{:error, {:decode_error, "Unsupported schema type: #{inspect(schema)}"}}
end
# ============================================================================
# Reference Resolution
# ============================================================================
defp resolve_ref("#/definitions/" <> path, definitions) do
# Handle URL-encoded paths (e.g., "cardano~1assets~1PolicyId" -> "cardano/assets/PolicyId")
decoded_path = URI.decode(path) |> String.replace("~1", "/")
case Map.get(definitions, decoded_path) || Map.get(definitions, path) do
nil -> {:error, {:encode_error, "Definition not found: #{path}"}}
schema -> {:ok, schema}
end
end
defp resolve_ref(ref, _definitions) do
{:error, {:encode_error, "Invalid reference format: #{ref}"}}
end
defp encode_bytes(value, %{"format" => "utf8"}) when is_binary(value) do
{:ok, %CBOR.Tag{tag: :bytes, value: value}}
end
defp encode_bytes(value, _schema) when is_binary(value) do
# Try to decode as hex first, otherwise use raw bytes
case Base.decode16(value, case: :mixed) do
{:ok, bytes} -> {:ok, %CBOR.Tag{tag: :bytes, value: bytes}}
:error -> {:ok, %CBOR.Tag{tag: :bytes, value: value}}
end
end
defp encode_bytes(%CBOR.Tag{tag: :bytes} = tag, _schema), do: {:ok, tag}
defp encode_bytes(value, _schema) do
{:error, {:encode_error, "Expected binary for bytes type, got: #{inspect(value)}"}}
end
defp encode_integer(value) when is_integer(value), do: {:ok, value}
defp encode_integer(value) do
{:error, {:encode_error, "Expected integer, got: #{inspect(value)}"}}
end
defp encode_list(values, item_schema, definitions) when is_list(values) do
results =
Enum.reduce_while(values, {:ok, []}, fn v, {:ok, acc} ->
case encode(v, item_schema, definitions) do
{:ok, encoded} -> {:cont, {:ok, [encoded | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, encoded_list} -> {:ok, %PList{value: Enum.reverse(encoded_list)}}
error -> error
end
end
defp encode_list(value, _item_schema, _definitions) do
{:error, {:encode_error, "Expected list, got: #{inspect(value)}"}}
end
defp encode_tuple(values, item_schemas, definitions) when is_list(values) or is_tuple(values) do
values_list = if is_tuple(values), do: Tuple.to_list(values), else: values
if length(values_list) != length(item_schemas) do
{:error,
{:encode_error,
"Tuple size mismatch: expected #{length(item_schemas)}, got #{length(values_list)}"}}
else
do_encode_tuple(values_list, item_schemas, definitions)
end
end
defp encode_tuple(value, _item_schemas, _definitions) do
{:error, {:encode_error, "Expected list or tuple, got: #{inspect(value)}"}}
end
defp do_encode_tuple(values_list, item_schemas, definitions) do
results =
Enum.zip(values_list, item_schemas)
|> Enum.reduce_while({:ok, []}, fn {v, schema}, {:ok, acc} ->
case encode(v, schema, definitions) do
{:ok, encoded} -> {:cont, {:ok, [encoded | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, encoded_list} -> {:ok, %PList{value: Enum.reverse(encoded_list)}}
error -> error
end
end
defp encode_map(value, keys_schema, values_schema, definitions) when is_map(value) do
results =
Enum.reduce_while(value, {:ok, []}, fn {k, v}, {:ok, acc} ->
with {:ok, encoded_key} <- encode(k, keys_schema, definitions),
{:ok, encoded_value} <- encode(v, values_schema, definitions) do
{:cont, {:ok, [{encoded_key, encoded_value} | acc]}}
else
error -> {:halt, error}
end
end)
case results do
{:ok, pairs} -> {:ok, Enum.reverse(pairs)}
error -> error
end
end
defp encode_map(value, _keys_schema, _values_schema, _definitions) do
{:error, {:encode_error, "Expected map, got: #{inspect(value)}"}}
end
defp encode_constructor(value, variants, definitions) do
cond do
is_nil(value) ->
encode_none_variant(variants)
should_encode_as_some?(value, variants) ->
encode_some_variant(value, variants, definitions)
true ->
encode_standard_variant(value, variants, definitions)
end
end
defp encode_none_variant(variants) do
case Enum.find(variants, fn v -> v["title"] == "None" end) do
nil -> {:error, {:encode_error, "Got nil but no None constructor found"}}
none_variant -> {:ok, %Constr{index: none_variant["index"], fields: []}}
end
end
defp should_encode_as_some?(value, variants) do
# Check if we have a raw value (not a map/struct) and there is a "Some" variant
# This detects if we should treat this as an Option.Some wrapping a value
!is_map(value) and Enum.any?(variants, fn v -> v["title"] == "Some" end)
end
defp encode_some_variant(value, variants, definitions) do
some_variant = Enum.find(variants, fn v -> v["title"] == "Some" end)
case some_variant["fields"] do
[schema] ->
encode(value, schema, definitions)
|> Utils.when_ok(fn encoded ->
{:ok, %Constr{index: some_variant["index"], fields: [encoded]}}
end)
_ ->
nil
end
end
defp encode_standard_variant(value, variants, definitions) do
case find_matching_variant(value, variants) do
{:ok, variant, index, field_values} ->
encode_constructor_fields(variant, index, field_values, definitions)
{:error, _} = error ->
error
end
end
defp find_matching_variant(%{constructor: constructor_name} = value, variants) do
case Enum.find_index(variants, fn v -> v["title"] == to_string(constructor_name) end) do
nil ->
{:error, {:encode_error, "No matching constructor found for: #{constructor_name}"}}
_idx ->
variant = Enum.find(variants, fn v -> v["title"] == to_string(constructor_name) end)
fields = Map.get(value, :fields, %{})
{:ok, variant, variant["index"], fields}
end
end
# Support for simple atom/string constructor names for unit constructors
defp find_matching_variant(constructor_name, variants)
when is_atom(constructor_name) or is_binary(constructor_name) do
name = to_string(constructor_name)
case Enum.find(variants, fn v -> v["title"] == name end) do
nil ->
{:error, {:encode_error, "No matching constructor found for: #{name}"}}
variant ->
if variant["fields"] == [] do
{:ok, variant, variant["index"], %{}}
else
{:error, {:encode_error, "Constructor #{name} requires fields, but none provided"}}
end
end
end
# Support for constructor by index
defp find_matching_variant({:constr, index, fields}, variants) when is_integer(index) do
case Enum.find(variants, fn v -> v["index"] == index end) do
nil ->
{:error, {:encode_error, "No constructor with index: #{index}"}}
variant ->
{:ok, variant, index, fields}
end
end
defp find_matching_variant(value, _variants) do
{:error,
{:encode_error,
"Invalid constructor value format. Expected %{constructor: name, fields: ...}, got: #{inspect(value)}"}}
end
defp encode_constructor_fields(variant, index, field_values, definitions) do
field_schemas = variant["fields"] || []
if field_schemas == [] and (field_values == %{} or field_values == [] or field_values == nil) do
{:ok, %Constr{index: index, fields: []}}
else
encoded_fields = encode_fields(field_values, field_schemas, definitions)
case encoded_fields do
{:ok, fields} -> {:ok, %Constr{index: index, fields: fields}}
error -> error
end
end
end
defp encode_fields(field_values, field_schemas, definitions) when is_map(field_values) do
# Named fields - match by title
results =
Enum.reduce_while(field_schemas, {:ok, []}, fn schema, {:ok, acc} ->
field_name = schema["title"]
field_value =
Map.get(field_values, field_name) || Map.get(field_values, String.to_atom(field_name))
case encode(field_value, schema, definitions) do
{:ok, encoded} -> {:cont, {:ok, [encoded | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, fields} -> {:ok, Enum.reverse(fields)}
error -> error
end
end
defp encode_fields(field_values, field_schemas, definitions) when is_list(field_values) do
# Positional fields
if length(field_values) != length(field_schemas) do
{:error,
{:encode_error,
"Field count mismatch: expected #{length(field_schemas)}, got #{length(field_values)}"}}
else
do_encode_fields_list(field_values, field_schemas, definitions)
end
end
defp do_encode_fields_list(field_values, field_schemas, definitions) do
results =
Enum.zip(field_values, field_schemas)
|> Enum.reduce_while({:ok, []}, fn {value, schema}, {:ok, acc} ->
case encode(value, schema, definitions) do
{:ok, encoded} -> {:cont, {:ok, [encoded | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, fields} -> {:ok, Enum.reverse(fields)}
error -> error
end
end
# ============================================================================
# Decoding Helpers
# ============================================================================
# Decode bytes returns hex-encoded string for consistency with defdata behavior
defp decode_bytes(%CBOR.Tag{tag: :bytes, value: value}, %{"format" => "utf8"}), do: {:ok, value}
# If we have a binary (e.g. from existing hex string), we assume it's already the value we want
# if it's utf8, OR we might need to decode it if it was hex encoded?
# Existing tests expect hex strings to be returned for standard bytes.
# For utf8, if we get "616161" (hex for "aaa"), we want "aaa".
# If we get "aaa" (raw), we want "aaa".
defp decode_bytes(value, %{"format" => "utf8"}) when is_binary(value) do
case Base.decode16(value, case: :mixed) do
{:ok, decoded} -> {:ok, decoded}
:error -> {:ok, value}
end
end
defp decode_bytes(%CBOR.Tag{tag: :bytes, value: value}, _schema),
do: {:ok, Base.encode16(value, case: :lower)}
defp decode_bytes(value, _schema) when is_binary(value),
do: {:ok, Base.encode16(value, case: :lower)}
defp decode_bytes(value, _schema) do
{:error, {:decode_error, "Expected bytes, got: #{inspect(value)}"}}
end
defp decode_integer(value) when is_integer(value), do: {:ok, value}
defp decode_integer(value) do
{:error, {:decode_error, "Expected integer, got: #{inspect(value)}"}}
end
defp decode_list(%PList{value: values}, item_schema, definitions) do
decode_list(values, item_schema, definitions)
end
defp decode_list(values, item_schema, definitions) when is_list(values) do
results =
Enum.reduce_while(values, {:ok, []}, fn v, {:ok, acc} ->
case decode(v, item_schema, definitions) do
{:ok, decoded} -> {:cont, {:ok, [decoded | acc]}}
error -> {:halt, error}
end
end)
case results do
{:ok, decoded_list} -> {:ok, Enum.reverse(decoded_list)}
error -> error
end
end
defp decode_list(value, _item_schema, _definitions) do
{:error, {:decode_error, "Expected list or PList, got: #{inspect(value)}"}}
end
defp decode_tuple(%PList{value: values}, item_schemas, definitions) do
decode_tuple(values, item_schemas, definitions)
end
defp decode_tuple(values, item_schemas, definitions) when is_list(values) do
if length(values) != length(item_schemas) do
{:error,
{:decode_error,
"Tuple size mismatch: expected #{length(item_schemas)}, got #{length(values)}"}}
else
do_decode_tuple(values, item_schemas, definitions)
end
end
defp decode_tuple(value, _item_schemas, _definitions) do
{:error, {:decode_error, "Expected list, PList or tuple, got: #{inspect(value)}"}}
end
defp do_decode_tuple(values, item_schemas, definitions) do
results =
Enum.zip(values, item_schemas)
|> Enum.reduce_while({:ok, []}, fn {v, schema}, {:ok, acc} ->
case decode(v, schema, definitions) do
{:ok, decoded} -> {:cont, {:ok, [decoded | acc]}}
error -> {:halt, error}
end
end)
case results do
# Return Elixir tuple, not list
{:ok, decoded_list} -> {:ok, List.to_tuple(Enum.reverse(decoded_list))}
error -> error
end
end
defp decode_map(pairs, keys_schema, values_schema, definitions) when is_list(pairs) do
results =
Enum.reduce_while(pairs, {:ok, %{}}, fn {k, v}, {:ok, acc} ->
with {:ok, decoded_key} <- decode(k, keys_schema, definitions),
{:ok, decoded_value} <- decode(v, values_schema, definitions) do
{:cont, {:ok, Map.put(acc, decoded_key, decoded_value)}}
else
error -> {:halt, error}
end
end)
results
end
defp decode_map(value, _keys_schema, _values_schema, _definitions) do
{:error, {:decode_error, "Expected list of pairs for map, got: #{inspect(value)}"}}
end
defp decode_constructor(%Constr{index: index, fields: fields}, variants, definitions) do
case Enum.find(variants, fn v -> v["index"] == index end) do
nil ->
{:error, {:decode_error, "No constructor with index: #{index}"}}
variant ->
decode_constructor_fields(variant, fields, definitions)
end
end
defp decode_constructor(value, _variants, _definitions) do
{:error, {:decode_error, "Expected Constr, got: #{inspect(value)}"}}
end
defp decode_constructor_fields(%{"title" => "None"}, _fields, _definitions) do
{:ok, nil}
end
defp decode_constructor_fields(
%{"title" => "Some", "fields" => [schema]},
[value],
definitions
) do
decode(value, schema, definitions)
end
defp decode_constructor_fields(variant, fields, definitions) do
field_schemas = variant["fields"] || []
title = variant["title"]
if field_schemas == [] do
{:ok, %{constructor: title, fields: %{}}}
else
if length(fields) != length(field_schemas) do
{:error,
{:decode_error,
"Field count mismatch for #{title}: expected #{length(field_schemas)}, got #{length(fields)}"}}
else
process_decoded_fields(title, fields, field_schemas, definitions)
end
end
end
defp process_decoded_fields(title, fields, field_schemas, definitions) do
case decode_fields(fields, field_schemas, definitions) do
{:ok, field_map} -> {:ok, %{constructor: title, fields: field_map}}
error -> error
end
end
defp decode_fields(field_values, field_schemas, definitions) do
Enum.zip(field_values, field_schemas)
|> Enum.reduce_while({:ok, %{}}, fn {value, schema}, {:ok, acc} ->
decode_field_and_put(value, schema, acc, definitions)
end)
end
defp decode_field_and_put(value, schema, acc, definitions) do
field_name = schema["title"]
case decode(value, schema, definitions) do
{:ok, decoded} ->
key = if field_name, do: field_name, else: map_size(acc)
{:cont, {:ok, Map.put(acc, key, decoded)}}
error ->
{:halt, error}
end
end
end