diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index bdf33e9..7077e57 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -8,8 +8,12 @@ cdk = { git = "https://github.com/cashubtc/cdk", tag = "v0.4.0" } rand = "0.8.5" assert_matches = "1.5.0" tokio = "1.41.0" +uuid = "1.11.0" [[test]] name = "nut06" path = "nut06.rs" +[[test]] +name = "nut05" +path = "nut05.rs" diff --git a/integration-tests/nut05.rs b/integration-tests/nut05.rs new file mode 100644 index 0000000..659f296 --- /dev/null +++ b/integration-tests/nut05.rs @@ -0,0 +1,41 @@ +use assert_matches::assert_matches; +use cdk::{ + amount::Amount, + nuts::{CurrencyUnit, MeltQuoteState}, + wallet::MeltQuote, +}; +use integration_tests::init_wallet; +use uuid::Uuid; + +#[tokio::test] +pub async fn melt_quote_ok() { + const URL: &str = "http://localhost:4000"; + let wallet = init_wallet(URL, CurrencyUnit::Sat).unwrap(); + + let bolt11_invoice = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); + let melt_quote = wallet.melt_quote(bolt11_invoice, None).await.unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + assert_matches!( + melt_quote, + MeltQuote { + id, + unit: _, + amount, + request: _, + fee_reserve, + state, + expiry, + payment_preimage + } if Uuid::try_parse(&id).is_ok() + && amount == Amount::from(10) + && fee_reserve == Amount::from(1) + && state == MeltQuoteState::Unpaid + && expiry >= now + && payment_preimage.is_none() + ) +} diff --git a/integration-tests/nut06.rs b/integration-tests/nut06.rs index 56662fa..03fc9b6 100644 --- a/integration-tests/nut06.rs +++ b/integration-tests/nut06.rs @@ -4,8 +4,8 @@ use assert_matches::assert_matches; use integration_tests::init_wallet; use cdk::nuts::{ - ContactInfo, CurrencyUnit, MintInfo, MintMethodSettings, MintVersion, NUT04Settings, Nuts, - PaymentMethod, PublicKey, + ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, + NUT04Settings, Nuts, PaymentMethod, PublicKey, }; #[tokio::test] @@ -25,10 +25,16 @@ pub async fn info() { .to_vec(), false, )) - .nut05(cdk::nuts::NUT05Settings { - methods: [].to_vec(), - disabled: true, - }); + .nut05(cdk::nuts::NUT05Settings::new( + [MeltMethodSettings { + method: PaymentMethod::Bolt11, + unit: CurrencyUnit::Sat, + min_amount: None, + max_amount: None, + }] + .to_vec(), + false, + )); let expected_name = "Cashubrew Cashu Mint"; let expected_pubkey = PublicKey::from_hex("0381094f72790bb014504dfc9213bd3c8450440f5d220560075dbf2f8113e9fa3e") diff --git a/lib/cashubrew/NUTs/NUT-04/routes.ex b/lib/cashubrew/NUTs/NUT-04/routes.ex index fb424b6..3b10e81 100644 --- a/lib/cashubrew/NUTs/NUT-04/routes.ex +++ b/lib/cashubrew/NUTs/NUT-04/routes.ex @@ -1,6 +1,6 @@ defmodule Cashubrew.Nuts.Nut04.Routes do @moduledoc """ - List the rest routes defined in the NUT-03 + List the rest routes defined in the NUT-04 """ @doc """ @@ -14,7 +14,7 @@ defmodule Cashubrew.Nuts.Nut04.Routes do The route to check a quote state """ def v1_mint_quote_for_quote_id do - v1_mint_quote() <> "/quote_id" + v1_mint_quote() <> "/:quote_id" end @doc """ diff --git a/lib/cashubrew/NUTs/NUT-04/info.ex b/lib/cashubrew/NUTs/NUT-04/settings.ex similarity index 59% rename from lib/cashubrew/NUTs/NUT-04/info.ex rename to lib/cashubrew/NUTs/NUT-04/settings.ex index e0ad828..c8bd95b 100644 --- a/lib/cashubrew/NUTs/NUT-04/info.ex +++ b/lib/cashubrew/NUTs/NUT-04/settings.ex @@ -1,4 +1,4 @@ -defmodule Cashubrew.Nuts.Nut04.Info do +defmodule Cashubrew.Nuts.Nut04.MintMethodSetting do @moduledoc """ The infos retlated to this Nut support """ @@ -10,15 +10,10 @@ defmodule Cashubrew.Nuts.Nut04.Info do @doc """ Return the map to be used in Nut06 info "nuts" field """ - def info do - %{ - methods: [ - %__MODULE__{ - method: "bolt11", - unit: "sat" - } - ], - disabled: false + def bolt11 do + %__MODULE__{ + method: "bolt11", + unit: "sat" } end end diff --git a/lib/cashubrew/NUTs/NUT-05/impl.ex b/lib/cashubrew/NUTs/NUT-05/impl.ex new file mode 100644 index 0000000..3f222ef --- /dev/null +++ b/lib/cashubrew/NUTs/NUT-05/impl.ex @@ -0,0 +1,50 @@ +defmodule Cashubrew.Nuts.Nut05.Impl do + @moduledoc """ + Implementation and structs of the NUT-05 + """ + alias Cashubrew.Schema + + defp percent_fee_reserve, do: 1 + defp min_fee_reserve, do: 1 + # 15min + defp melt_quote_validity_duration_in_sec, do: 900 + + def create_melt_quote!(request, unit) do + {:ok, ln_invoice} = Bitcoinex.LightningNetwork.decode_invoice(request) + + repo = Application.get_env(:cashubrew, :repo) + + amount = + case unit do + "sat" -> div(ln_invoice.amount_msat, 1000) + _ -> raise "UnsupportedUnit" + end + + # TODO: impl max_amout min_amount config checks + + relative_fee_reserve = amount * percent_fee_reserve() / 100 + fee = max(relative_fee_reserve, min_fee_reserve()) + + expiry = System.os_time(:second) + melt_quote_validity_duration_in_sec() + quote_id = Ecto.UUID.bingenerate() + quote_id_as_string = Ecto.UUID.cast!(quote_id) + + Schema.MeltQuote.create!(repo, %{ + id: quote_id, + request: request, + unit: unit, + amount: amount, + fee_reserve: fee, + expiry: expiry, + request_lookup_id: ln_invoice.payment_hash + }) + + %{ + quote: quote_id_as_string, + amount: amount, + fee_reserve: fee, + state: "UNPAID", + expiry: expiry + } + end +end diff --git a/lib/cashubrew/NUTs/NUT-05/routes.ex b/lib/cashubrew/NUTs/NUT-05/routes.ex new file mode 100644 index 0000000..3c47e3a --- /dev/null +++ b/lib/cashubrew/NUTs/NUT-05/routes.ex @@ -0,0 +1,26 @@ +defmodule Cashubrew.Nuts.Nut05.Routes do + @moduledoc """ + List the rest routes defined in the NUT-05 + """ + + @doc """ + The route to ask the melt for a quote + """ + def v1_melt_quote do + "/v1/melt/quote/:method" + end + + @doc """ + The route to check a melt quote state + """ + def v1_melt_quote_for_quote_id do + v1_melt_quote() <> "/:quote_id" + end + + @doc """ + The route proceed to the melt + """ + def v1_melt do + "/v1/melt/:method" + end +end diff --git a/lib/cashubrew/NUTs/NUT-05/serde.ex b/lib/cashubrew/NUTs/NUT-05/serde.ex new file mode 100644 index 0000000..47740fc --- /dev/null +++ b/lib/cashubrew/NUTs/NUT-05/serde.ex @@ -0,0 +1,35 @@ +defmodule Cashubrew.Nuts.Nut05.Serde.PostMeltQuoteBolt11Request do + @moduledoc """ + The body of the post melt quote request + """ + @enforce_keys [:request, :unit] + @derive [Jason.Encoder] + defstruct [:request, :unit] +end + +defmodule Cashubrew.Nuts.Nut05.Serde.PostMeltBolt11Request do + @moduledoc """ + The body of the post melt request + """ + alias Cashubrew.Nuts.Nut00.Proof + + @enforce_keys [:quote, :inputs] + @derive [Jason.Encoder] + defstruct [:quote, :inputs] + + def from_map(map) do + %__MODULE__{ + quote: Map.fetch!(map, "quote"), + inputs: Proof.from_list(Map.fetch!(map, "inputs")) + } + end +end + +defmodule Cashubrew.Nuts.Nut05.Serde.PostMeltQuoteBolt11Response do + @moduledoc """ + The body of the post melt quote response + """ + @enforce_keys [:quote, :amount, :fee_reserve, :state, :expiry] + @derive [Jason.Encoder] + defstruct [:quote, :amount, :fee_reserve, :state, :expiry, :payment_preimage] +end diff --git a/lib/cashubrew/NUTs/NUT-05/settings.ex b/lib/cashubrew/NUTs/NUT-05/settings.ex new file mode 100644 index 0000000..f627ae9 --- /dev/null +++ b/lib/cashubrew/NUTs/NUT-05/settings.ex @@ -0,0 +1,19 @@ +defmodule Cashubrew.Nuts.Nut05.MeltMethodSetting do + @moduledoc """ + The infos retlated to this Nut support + """ + + @enforce_keys [:method, :unit] + @derive [Jason.Encoder] + defstruct [:method, :unit, :min_amount, :max_amount, :description] + + @doc """ + Return the map to be used in Nut06 info "nuts" field + """ + def bolt11 do + %__MODULE__{ + method: "bolt11", + unit: "sat" + } + end +end diff --git a/lib/cashubrew/NUTs/NUT-06/info.ex b/lib/cashubrew/NUTs/NUT-06/info.ex index 794c198..7398923 100644 --- a/lib/cashubrew/NUTs/NUT-06/info.ex +++ b/lib/cashubrew/NUTs/NUT-06/info.ex @@ -3,6 +3,7 @@ defmodule Cashubrew.Nuts.Nut06.Info do Implementation and structs of the NUT-06 """ alias Cashubrew.Nuts.Nut04 + alias Cashubrew.Nuts.Nut05 @derive [Jason.Encoder] defstruct [ @@ -46,8 +47,8 @@ defmodule Cashubrew.Nuts.Nut06.Info do ], time: System.os_time(:second), nuts: %{ - "4": Nut04.Info.info(), - "5": %{methods: [], disabled: true}, + "4": %{methods: [Nut04.MintMethodSetting.bolt11()], disabled: false}, + "5": %{methods: [Nut05.MeltMethodSetting.bolt11()], disabled: false}, "7": %{supported: false}, "8": %{supported: false}, "9": %{supported: false}, diff --git a/lib/cashubrew/schema/melt_quote.ex b/lib/cashubrew/schema/melt_quote.ex index 3eefe21..fea0357 100644 --- a/lib/cashubrew/schema/melt_quote.ex +++ b/lib/cashubrew/schema/melt_quote.ex @@ -5,6 +5,7 @@ defmodule Cashubrew.Schema.MeltQuote do use Ecto.Schema import Ecto.Changeset + @primary_key {:id, :binary_id, autogenerate: false} schema "melt_quote" do field(:request, :string) field(:unit, :string) @@ -22,4 +23,14 @@ defmodule Cashubrew.Schema.MeltQuote do |> cast(attrs, [:request, :unit, :amount, :fee_reserve, :expiry, :request_lookup_id]) |> validate_required([:request, :unit, :amount, :fee_reserve, :expiry, :request_lookup_id]) end + + def create!(repo, values) do + %__MODULE__{} + |> changeset(values) + |> repo.insert() + |> case do + {:ok, _} -> nil + {:error, changeset} -> raise "Failed to insert key: #{inspect(changeset.errors)}" + end + end end diff --git a/lib/cashubrew/web/controllers/mint_controller.ex b/lib/cashubrew/web/controllers/mint_controller.ex index 08614c4..84b976d 100644 --- a/lib/cashubrew/web/controllers/mint_controller.ex +++ b/lib/cashubrew/web/controllers/mint_controller.ex @@ -2,12 +2,14 @@ defmodule Cashubrew.Web.MintController do use Cashubrew.Web, :controller require :logger require Logger + alias Cashubrew.Lightning alias Cashubrew.Mint alias Cashubrew.Nuts.Nut00 alias Cashubrew.Nuts.Nut01 alias Cashubrew.Nuts.Nut02 alias Cashubrew.Nuts.Nut03 alias Cashubrew.Nuts.Nut04 + alias Cashubrew.Nuts.Nut05 alias Cashubrew.Nuts.Nut06 def info(conn, _params) do @@ -62,7 +64,7 @@ defmodule Cashubrew.Web.MintController do } = params["body"] res = Nut04.Impl.create_mint_quote!(amount, unit) - json(conn, struct(Nut04.Serde.PostMintBolt11Response, %{res | state: "UNPAID"})) + json(conn, struct(Nut04.Serde.PostMintBolt11Response, Map.put(res, :state, "UNPAID"))) rescue e in RuntimeError -> conn |> put_status(:bad_request) |> json(Nut00.Error.new_error(0, e)) end @@ -94,25 +96,30 @@ defmodule Cashubrew.Web.MintController do e in RuntimeError -> conn |> put_status(:bad_request) |> json(Nut00.Error.new_error(0, e)) end - def melt_quote(conn, %{"request" => request, "unit" => unit}) do - case Mint.create_melt_quote(request, unit) do - {:ok, quote} -> - conn - |> put_status(:created) - |> json(%{ - request: quote.request, - quote: quote.id, - amount: quote.amount, - fee_reserve: quote.fee_reserve, - state: "UNPAID", - expiry: quote.expiry - }) + def create_melt_quote(conn, params) do + method = params["method"] - {:error, reason} -> - conn - |> put_status(:bad_request) - |> json(%{error: reason}) + if method != "bolt11" do + raise "UnsuportedMethod" + end + + request = params["request"] + + if !request do + raise "NoRequest" + end + + unit = params["unit"] + + if !unit do + raise "NoUnit" end + + res = Nut05.Impl.create_melt_quote!(request, unit) + + json(conn, struct(Nut05.Serde.PostMeltQuoteBolt11Response, res)) + rescue + e in RuntimeError -> conn |> put_status(:bad_request) |> json(Nut00.Error.new_error(0, e)) end def melt_tokens(conn, %{"quote_id" => quote_id, "inputs" => inputs}) do diff --git a/lib/cashubrew/web/router.ex b/lib/cashubrew/web/router.ex index 30ee8bd..9a8a28b 100644 --- a/lib/cashubrew/web/router.ex +++ b/lib/cashubrew/web/router.ex @@ -5,6 +5,7 @@ defmodule Cashubrew.Web.Router do alias Cashubrew.Nuts.Nut02 alias Cashubrew.Nuts.Nut03 alias Cashubrew.Nuts.Nut04 + alias Cashubrew.Nuts.Nut05 pipeline :browser do plug(:accepts, ["html"]) @@ -35,14 +36,15 @@ defmodule Cashubrew.Web.Router do get(Nut04.Routes.v1_mint_quote_for_quote_id(), MintController, :get_mint_quote) post(Nut04.Routes.v1_mint(), MintController, :mint_tokens) - # NUT-05 - post("/v1/melt/quote/bolt11", MintController, :melt_quote) post("/v1/melt/bolt11", MintController, :melt_tokens) end scope "/", Cashubrew.Web do + pipe_through(:api) # NUT-06 get("/v1/info", MintController, :info) + # NUT-05 + post(Nut05.Routes.v1_melt_quote(), MintController, :create_melt_quote) end if Mix.env() == :dev do diff --git a/priv/repo/migrations/20240918113122_create_mint_quote.exs b/priv/repo/migrations/20240918113122_create_mint_quote.exs index 3988309..d774884 100644 --- a/priv/repo/migrations/20240918113122_create_mint_quote.exs +++ b/priv/repo/migrations/20240918113122_create_mint_quote.exs @@ -13,10 +13,4 @@ defmodule Cashubrew.Repo.Migrations.CreateMintQuote do timestamps() end end - - # def change do - # alter table(:mint_quotes) do - # modify :payment_request, :text, null: false - # end - # end end diff --git a/priv/repo/migrations/20241029214804_change_melt_quote_request_type_to_text.exs b/priv/repo/migrations/20241029214804_change_melt_quote_request_type_to_text.exs new file mode 100644 index 0000000..677836b --- /dev/null +++ b/priv/repo/migrations/20241029214804_change_melt_quote_request_type_to_text.exs @@ -0,0 +1,9 @@ +defmodule Cashubrew.Repo.Migrations.ChangeMeltQuoteRequestTypeToText do + use Ecto.Migration + + def change do + alter table :melt_quote do + modify :request, :text + end + end +end