% @doc % GRIDS library: grids % % This module simply handles encoding and decoding of GRIDS URLs. % % For documentation on GRIDS see % % https://git.qpq.swiss/QPQ-AG/research-megadoc/wiki/GRIDS % @end -module(gmgrids). -vsn("0.2.0"). -author("Peter Harpending "). -copyright("2025 QPQ AG"). -license("MIT"). % TODONE: % % TODO: % % - possibly input types should be iolists. I think for % binaries it's fine % - change the types... don't need 3 different record types % FIXEDME: % % FIXME: -export_type([ % field types host/0, target/0, akstr/0, pubkey/0, % record types grids/0 ]). -export([ % convenience functions %% currency granularities p/0, kp/0, mp/0, gp/0, tp/0, pp/0, g/0, kg/0, mg/0, gg/0, tg/0, %% type fuckery target_to_path/1, akstr/1, unakstr/1, dummy_target/0, %% convenience encoders encode/2, % "primitives" %% record constructor mk_grids/2, %% primitive encoders encode/1, encode/7, percent_encode/1, %% decoder decode/1 ]). %%------------------------------------------------------------------- %% API: TYPES %%------------------------------------------------------------------- % @doc Future-proofing... later this could be inet:addr() or whatever, or maybe % {Host, Port}. Keeping it simple for now -type host() :: string(). % @doc ak_... string -type akstr() :: string(). % @doc 32-byte public key -type pubkey() :: <<_:256>>. % @doc later might want this to be flexible, "ak_..." etc % % FIXME: add support for all the different api keys: ak_..., ct_..., etc -type target() :: pubkey() | akstr(). -record(grids, {secure = true :: boolean(), host :: host(), version = 1 :: integer(), instruction :: dead_drop | spend | transfer, path :: string(), amount = none :: none | {value, integer()}, payload = none :: none | {value, binary()}}). -type grids() :: #grids{}. %%------------------------------------------------------------------- %% API: CONVENIENCE FUNCTIONS %%------------------------------------------------------------------- %% currency granularities p() -> 1. kp() -> 1_000. mp() -> 1_000_000. gp() -> 1_000_000_000. tp() -> 1_000_000_000_000. pp() -> 1_000_000_000_000_000. g() -> 1_000_000_000_000_000_000. kg() -> 1_000_000_000_000_000_000_000. mg() -> 1_000_000_000_000_000_000_000_000. gg() -> 1_000_000_000_000_000_000_000_000_000. tg() -> 1_000_000_000_000_000_000_000_000_000_000. -spec target_to_path(Target) -> Path when Target :: target(), Path :: string(). % @doc % Internal function exported for convenience purposes % % If `Target' is an "ak_..." string, leave as-is. If it's a 32 byte public key % encode as an ak_... string target_to_path(Target) -> i_ttp(iolist_to_binary(Target)). i_ttp(ApiStr = <<"ak_", _/binary>>) -> ApiStr; i_ttp(Pubkey = <<_:32/bytes>>) -> akstr(Pubkey); i_ttp(BadTarget) -> error({invalid_target, BadTarget}). %% akstr/unakstr -spec akstr(Pubkey) -> AkStr when Pubkey :: pubkey(), AkStr :: akstr(). % @doc % convert a 32-byte public key into an ak_... string akstr(PK) -> unicode:characters_to_list(gmser_api_encoder:encode(account_pubkey, PK)). -spec unakstr(AkStr) -> Pubkey when Pubkey :: pubkey(), AkStr :: string(). % @doc % convert an ak_... string into a 32-byte public key unakstr(Akstr) -> {_, PK} = gmser_api_encoder:decode(unicode:characters_to_binary(Akstr)), PK. -spec dummy_target() -> akstr(). % @doc Make a dummy public key. For testing purposes. NOT secure! dummy_target() -> akstr(rand:bytes(32)). -spec encode(Args, Options) -> URL when Args :: {dead_drop, Host, Path} | {spend, NetworkId, Recipient} | {transfer, Host, Path}, Host :: iolist(), Path :: iolist(), NetworkId :: iolist(), Recipient :: target(), Options :: [Opt], Opt :: {secure, boolean()} | {version, integer()} | {amount, Amount} | {payload, Payload}, Amount :: integer() | none | {value, integer()}, Payload :: iolist() | none | {value, iolist()}, URL :: string(). encode(Args, Opts) -> encode(mk_grids(Args, Opts)). %%------------------------------------------------------------------- %% API: RECORD CONSTRUCTORS %%------------------------------------------------------------------- -record(o, {secure = true :: boolean(), version = 1 :: integer(), amount = none :: none | {value, integer()}, payload = none :: none | {value, binary()}}). -spec mk_grids(Args, Options) -> Grids when Args :: {dead_drop, Host, Path} | {spend, NetworkId, Recipient} | {transfer, Host, Path}, Host :: iolist(), Path :: iolist(), NetworkId :: iolist(), Recipient :: target(), Options :: [Opt], Opt :: {secure, boolean()} | {version, integer()} | {amount, Amount} | {payload, Payload}, Amount :: integer() | none | {value, integer()}, Payload :: iolist() | none | {value, iolist()}, Grids :: #grids{}. mk_grids(Args, Options) -> #o{secure = Secure, version = Version, amount = MaybeAmount, payload = MaybePayload} = i_valid_opts(Options), {Instruction, HostStr, Path} = case Args of {dead_drop, H0, P0} -> H1 = unicode:characters_to_list(H0), P1 = unicode:characters_to_list(P0), {dead_drop, H1, P1}; {spend, NetId0, Recip0} -> NetId1 = unicode:characters_to_list(NetId0), Recip1 = target_to_path(Recip0), {spend, NetId1, Recip1}; {transfer, H0, P0} -> H1 = unicode:characters_to_list(H0), P1 = unicode:characters_to_list(P0), {transfer, H1, P1} end, #grids{secure = Secure, host = HostStr, version = Version, instruction = Instruction, path = Path, amount = MaybeAmount, payload = MaybePayload}. i_valid_opts(Options) -> Secure = case proplists:get_value(secure, Options, true) of S when is_boolean(S) -> S; W -> error({invalid_option, {secure, W}}) end, Version = case proplists:get_value(version, Options, 1) of V when is_integer(V) -> V; X -> error({invalid_option, {version, X}}) end, Amount = case proplists:get_value(amount, Options, none) of none -> none; {value, N} when is_integer(N) -> {value, N}; N when is_integer(N) -> {value, N}; Y -> error({invalid_option, {amount, Y}}) end, Payload = case proplists:get_value(payload, Options, none) of none -> none; {value, P} -> {value, iolist_to_binary(P)}; P -> {value, iolist_to_binary(P)} end, #o{secure = Secure, version = Version, amount = Amount, payload = Payload}. %%------------------------------------------------------------------- %% API: ENCODING (Record -> URL) %%------------------------------------------------------------------- -spec encode(GRIDS) -> URL when GRIDS :: grids(), URL :: string(). % @doc % Encode a grids record type % @end encode(#grids{secure = Secure, host = Host, version = Vsn, instruction = Instruction, path = Path, amount = Amt, payload = Payload}) -> encode(Secure, Host, Vsn, Instruction, Path, Amt, Payload). -spec encode(Secure, Host, Version, Instruction, Path, Amount, Payload) -> URL when Secure :: boolean(), Host :: host(), Version :: integer(), Instruction :: dead_drop | spend | transfer, Path :: string(), Amount :: none | {value, integer()}, Payload :: none | {value, binary()}, URL :: string(). % @doc % internal encode that's more verbose encode(Secure, Host, Version, Instruction, Path, Amount, Payload) -> unicode:characters_to_list( ["grid", i_encode_secure(Secure), "://", i_encode_host(Host), "/", integer_to_list(Version), "/", i_encode_instruction(Instruction), "/", Path, i_encode_qstr(Amount, Payload)] ). i_encode_secure(true) -> "s"; i_encode_secure(false) -> "". % future-proofing against more complicated host arguments i_encode_host(Host) -> Host. i_encode_instruction(dead_drop) -> "d"; i_encode_instruction(spend) -> "s"; i_encode_instruction(transfer) -> "t". i_encode_qstr(none, none) -> ""; i_encode_qstr({value, Amt}, none) -> ["?a=", integer_to_list(Amt)]; i_encode_qstr(none, {value, Payload}) -> ["?p=", percent_encode(Payload)]; i_encode_qstr({value, Amt}, {value, Payload}) -> ["?a=", integer_to_list(Amt), "&p=", percent_encode(Payload)]. -spec percent_encode(Payload) -> PercentEncoded when Payload :: binary(), PercentEncoded :: iolist(). % @doc % internal function to percent-encode binary payload % exported for convenience % % See: https://en.wikipedia.org/wiki/Percent-encoding percent_encode(Payload) when is_binary(Payload) -> i_percent_encode(Payload, []). % unreserved characters i_percent_encode(<>, Acc) when ($A =< C andalso C =< $Z) orelse ($a =< C andalso C =< $z) orelse ($0 =< C andalso C =< $9) orelse (C =:= $-) orelse (C =:= $_) orelse (C =:= $~) orelse (C =:= $.) -> i_percent_encode(Rest, [Acc, C]); i_percent_encode(<>, Acc) -> i_percent_encode(Rest, [Acc, i_pe_byte(B)]); i_percent_encode(<<>>, Result) -> Result. % single hex digit i_pe_byte(B) when 16#00 =< B, B =< 16#0F -> ["%0", integer_to_list(B, 16)]; i_pe_byte(B) when 16#10 =< B, B =< 16#FF -> ["%", integer_to_list(B, 16)]. %%--------------------------------------------------------- %% API: DECODING %%--------------------------------------------------------- -record(dt, {secure = undefined :: undefined | boolean(), host = undefined :: undefined | iolist(), version = undefined :: undefined | integer(), instruction = undefined :: undefined | spend | transfer | dead_drop, path = undefined :: undefined | iolist(), amount = undefined :: undefined | none | {value, integer()}, payload = undefined :: undefined | none | {value, binary()}}). % -type decode_target() :: #dt{}. -spec decode(URL) -> Result when URL :: string(), Result :: {ok, grids(), Remainder :: string()} | {error, Reason}, Reason :: term(). decode(URL) -> case i_decode(unicode:characters_to_binary(URL)) of {ok, DT, Remainder} -> {ok, i_decode_dt(DT), unicode:characters_to_list(Remainder)}; Error -> Error end. i_decode_dt(#dt{secure = S, host = H, version = V, path = P, instruction = Instruction, amount = A, payload = L}) -> HStr = unicode:characters_to_list(H), PStr = unicode:characters_to_list(P), #grids{secure = S, host = HStr, version = V, path = PStr, instruction = Instruction, amount = A, payload = L}. i_decode(URL) -> i_pipeline([fun i_decode_secure/2, fun i_decode_host/2, fun i_decode_version/2, fun i_decode_instruction/2, fun i_decode_path/2, fun i_decode_qstr/2], {ok, #dt{}, URL}). i_pipeline([Fun | Funs], Acc) -> case Acc of {ok, DT, URL} -> i_pipeline(Funs, Fun(DT, URL)); Error -> Error end; i_pipeline([], Result) -> Result. i_decode_secure(DT = #dt{secure = undefined}, <<"grid://", Rest/binary>>) -> {ok, DT#dt{secure = false}, Rest}; i_decode_secure(DT = #dt{secure = undefined}, <<"grids://", Rest/binary>>) -> {ok, DT#dt{secure = true}, Rest}; i_decode_secure(_, URL) -> {error, {bad_protocol, URL}}. i_decode_host(DT = #dt{host = undefined}, URL) -> case idh2([], URL) of {ok, Host, Rest} -> {ok, DT#dt{host = Host}, Rest}; Error -> Error end. % eliminate empty hosts and hosts not followed by / idh2([], <<$/:8, _/binary>>) -> {error, empty_host}; idh2([], <<>>) -> {error, empty_host}; idh2(Host, <<>>) -> {error, {bad_host, Host}}; idh2(Host, <<$/:8, Rest/binary>>) -> {ok, Host, Rest}; idh2(Acc, <>) -> idh2([Acc, Char], Rest). i_decode_version(DT = #dt{version = undefined}, URL) -> case idv2([], URL) of {ok, VStr, Rest} -> Version = list_to_integer(unicode:characters_to_list(VStr)), NewDT = DT#dt{version = Version}, {ok, NewDT, Rest}; Error -> Error end. idv2([], <<$/:8, _/binary>>) -> {error, empty_host}; idv2([], <<>>) -> {error, empty_host}; idv2(Vstr, <<>>) -> {error, {bad_version, iolist_to_binary(Vstr)}}; idv2(Vstr, <<$/:8, Rest/binary>>) -> {ok, Vstr, Rest}; idv2(Acc, <>) -> case ($0 =< N) andalso (N =< $9) of true -> idv2([Acc, N], Rest); false -> {error, {illegal_version_char, [N]}} end. i_decode_instruction(DT = #dt{instruction = undefined}, <<"s/", Rest/binary>>) -> {ok, DT#dt{instruction = spend}, Rest}; i_decode_instruction(DT = #dt{instruction = undefined}, <<"t/", Rest/binary>>) -> {ok, DT#dt{instruction = transfer}, Rest}; i_decode_instruction(DT = #dt{instruction = undefined}, <<"d/", Rest/binary>>) -> {ok, DT#dt{instruction = dead_drop}, Rest}; i_decode_instruction(_ = #dt{instruction = undefined}, Bad) -> {error, {illegal_instruction, Bad}}. i_decode_path(DT = #dt{path = undefined}, URL) -> {Path, Rest} = idp([], URL), {ok, DT#dt{path = Path}, Rest}. % consume until we get to end of string or ? idp(Path, <<"?", Rest/binary>>) -> {Path, Rest}; idp(Path, <<>>) -> {Path, <<>>}; idp(Path, <>) -> idp([Path, C], Rest). i_decode_qstr(DT = #dt{amount = undefined, payload = undefined}, URL) -> case idq([], URL) of {ok, Proplist, Remainder} -> Amount = proplists:get_value(amount, Proplist, none), Payload = proplists:get_value(payload, Proplist, none), NewDT = DT#dt{amount = Amount, payload = Payload}, {ok, NewDT, Remainder}; Error -> Error end. -spec idq(Proplist, URL) -> Result when URL :: binary(), Result :: {ok, Proplist, Remainder} | {error, ParseError}, Proplist :: [Prop], Prop :: {amount, {value, integer()}} | {payload, {value, binary()}}, ParseError :: any(), Remainder :: binary(). idq(Params, <<"a=", Rest/binary>>) -> case i_parse_amt(none, Rest) of {ok, Amt, NewRest} -> NewParams = [{amount, {value, Amt}} | Params], idq(NewParams, NewRest); Error -> Error end; idq(Params, <<"p=", Rest/binary>>) -> case i_parse_payload(none, Rest) of {ok, Payload, NewRest} -> NewParams = [{payload, {value, Payload}} | Params], idq(NewParams, NewRest); Error -> Error end; idq(Params, Rest) -> {ok, Params, Rest}. -spec i_parse_amt(MaybeAmount, URL) -> Result when URL :: binary(), Result :: {ok, MaybeAmount, Rest} | {error, term()}, MaybeAmount :: none | {value, integer()}, Rest :: binary(). % @private context here is we have an a= and we're parsing what comes after % that % % we can error on empty amounts i_parse_amt(Acc, <>) when $0 =< DigitChar, DigitChar =< $9 -> DigitInt = DigitChar - $0, NewAcc = case Acc of none -> {value, DigitInt}; {value, N} -> {value, N*10 + DigitInt} end, i_parse_amt(NewAcc, Rest); % either end of string or non-digit char i_parse_amt(Acc, <<"&", Rest/binary>>) -> case Acc of {value, Amount} -> {ok, Amount, Rest}; none -> {error, empty_amount} end; i_parse_amt(Acc, Rest) -> case Acc of {value, Amount} -> {ok, Amount, Rest}; none -> {error, empty_amount} end. -define(IS_HEX_CHAR(P), ((($0 =< P) andalso (P =< $9)) orelse (($A =< P) andalso (P =< $F)))). -spec i_parse_payload(MaybePayload, URL) -> Result when URL :: binary(), Result :: {ok, MaybePayload, Rest} | {error, term()}, MaybePayload :: none | {value, binary()}, Rest :: binary(). % unreserved chars i_parse_payload(Acc, <>) when ($A =< C andalso C =< $Z) orelse ($a =< C andalso C =< $z) orelse ($0 =< C andalso C =< $9) orelse (C =:= $-) orelse (C =:= $_) orelse (C =:= $~) orelse (C =:= $.) -> NewAcc = case Acc of none -> {value, <>}; {value, Bytes} -> {value, <>} end, i_parse_payload(NewAcc, Rest); % percent char i_parse_payload(Acc, <<"%", A:8, B:8, Rest/binary>>) when ?IS_HEX_CHAR(A), ?IS_HEX_CHAR(B) -> AInt = list_to_integer([A], 16), BInt = list_to_integer([B], 16), NewByte = AInt*16 + BInt, NewAcc = case Acc of none -> {value, <>}; {value, Bytes} -> {value, <>} end, i_parse_payload(NewAcc, Rest); % random char i_parse_payload(Acc, <<"&", Rest/binary>>) -> case Acc of none -> {error, empty_payload}; {value, Payload} -> {ok, Payload, Rest} end; i_parse_payload(Acc, Rest) -> case Acc of none -> {error, {illegal_payload, Rest}}; {value, Payload} -> {ok, Payload, Rest} end.