diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f21371..dd78c02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,9 +71,12 @@ jobs: with: shared-key: "cache" save-if: false + workspaces: integration-tests -> ./integration-tests - name: Setup db run: mix ecto.setup + env: + MIX_ENV: test - name: Integration tests run: |- @@ -82,16 +85,24 @@ jobs: cargo run & BTCD_AND_LND_SERVERS_PID=$! # Wait until the nodes are running by checking if the the env var are exported - while [[ -z "${LND_URL}" ]]; do sleep 1 && source .env; done + while [[ -z "${LND_URL}" ]]; do sleep 5 && source .env; done cd .. # mix doesn't behave when run in background, so we use `erlang -detached` instead # but the `$!` thing won't work coz the app is run in another thread, # so we write the actual pid in a file and later read it to kill it elixir --erl "-detached" -e "File.write! 'pid', :os.getpid" -S mix phx.server cd integration-tests + # Wait until the mix is finished compiling + curl http://127.0.0.1:4000 cargo test cat ../pid | xargs kill kill $BTCD_AND_LND_SERVERS_PID + env: + MIX_ENV: dev + + - name: Setup tmate session + if: ${{ failure() }} + uses: mxschmitt/action-tmate@v3 diff --git a/config/runtime.exs b/config/runtime.exs index cafe6b4..17a22fe 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1,6 +1,13 @@ import Config -config :cashubrew, :lnd_client, Cashubrew.LightingNetwork.MockLnd +lnd_client = + case config_env() do + :dev -> Cashubrew.LightingNetwork.Lnd + :prod -> Cashubrew.LightingNetwork.Lnd + _ -> Cashubrew.LightingNetwork.MockLnd + end + +config :cashubrew, :lnd_client, lnd_client if config_env() == :prod do database_url = diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index ace6c18..882f38e 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -14,11 +14,16 @@ bitcoind = { version = "0.34.2", features = ["26_0"] } lnd = { version = "0.1.6", features = ["lnd_0_17_5"] } tonic_lnd = "0.5.1" ctrlc = "3.4" +lightning-invoice = { version = "0.32.0" } [[test]] -name = "nut06" -path = "nut06.rs" +name = "nut04" +path = "nut04.rs" [[test]] name = "nut05" path = "nut05.rs" + +[[test]] +name = "nut06" +path = "nut06.rs" diff --git a/integration-tests/nut04.rs b/integration-tests/nut04.rs new file mode 100644 index 0000000..37b5c55 --- /dev/null +++ b/integration-tests/nut04.rs @@ -0,0 +1,45 @@ +use std::str::FromStr; + +use assert_matches::assert_matches; +use cdk::{ + amount::Amount, + mint_url::MintUrl, + nuts::{CurrencyUnit, MintQuoteState}, + wallet::MintQuote, +}; +use integration_tests::init_wallet; +use lightning_invoice::Bolt11Invoice; +use uuid::Uuid; + +#[tokio::test] +pub async fn mint_quote_ok() { + const CASHU_MINT_URL: &str = "http://localhost:4000"; + let wallet = init_wallet(CASHU_MINT_URL, CurrencyUnit::Sat).unwrap(); + let quote_amount = Amount::from(100); + + let quote = wallet.mint_quote(quote_amount).await.unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + assert_matches!( + quote, + MintQuote { + ref id, + mint_url, + amount, + unit, + request, + state, + expiry + } if Uuid::try_parse(id).is_ok() + && mint_url == MintUrl::from_str(CASHU_MINT_URL).unwrap() + && amount == quote_amount + && unit == CurrencyUnit::Sat + && Bolt11Invoice::from_str(&request).is_ok() + && state == MintQuoteState::Unpaid + && expiry >= now + ); +} diff --git a/integration-tests/nut05.rs b/integration-tests/nut05.rs index 07c64f9..1570149 100644 --- a/integration-tests/nut05.rs +++ b/integration-tests/nut05.rs @@ -9,8 +9,8 @@ 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(); + const CASHU_MINT_URL: &str = "http://localhost:4000"; + let wallet = init_wallet(CASHU_MINT_URL, CurrencyUnit::Sat).unwrap(); let bolt11_invoice = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); let melt_quote = wallet.melt_quote(bolt11_invoice, None).await.unwrap(); diff --git a/lib/cashubrew/NUTs/NUT-04/impl.ex b/lib/cashubrew/NUTs/NUT-04/impl.ex index ec865cc..7ff5dcd 100644 --- a/lib/cashubrew/NUTs/NUT-04/impl.ex +++ b/lib/cashubrew/NUTs/NUT-04/impl.ex @@ -7,35 +7,39 @@ defmodule Cashubrew.Nuts.Nut04.Impl do alias Cashubrew.Nuts.Nut04.Impl.MintQuoteMutex alias Cashubrew.Schema - def create_mint_quote!(amount, unit, description) do - # Todo: sanitize earlier? - # amount = if is_integer(amount), do: amount, else: String.to_integer(amount) + require Logger + def create_mint_quote!(amount, unit, description) do repo = Application.get_env(:cashubrew, :repo) lnd_client = Application.get_env(:cashubrew, :lnd_client) - {:ok, payment_request} = lnd_client.create_invoice!(amount, unit, description) + {validity, + %{ + r_hash: r_hash, + payment_request: payment_request, + add_index: add_index, + payment_addr: payment_addr + }} = lnd_client.create_invoice!(amount, unit, description) # Note: quote is a unique and random id generated by the mint to internally look up the payment state. # quote MUST remain a secret between user and mint and MUST NOT be derivable from the payment request. # A third party who knows the quote ID can front-run and steal the tokens that this operation mints. - quote_id = Ecto.UUID.bingenerate() - quote_id_as_string = Ecto.UUID.cast!(quote_id) + quote_id = Ecto.UUID.generate() - # 1 hour expiry - expiry = :os.system_time(:second) + 3600 + expiry = :os.system_time(:second) + validity Schema.MintQuote.create!(repo, %{ id: quote_id, + r_hash: r_hash, payment_request: payment_request, - amount: amount, - unit: unit, - expiry: expiry, + add_index: add_index, + payment_addr: payment_addr, + description: description, # Unpaid state: <<0>> }) - %{quote_id: quote_id_as_string, request: payment_request, expiry: expiry} + %{quote: quote_id, request: payment_request, expiry: expiry, state: "UNPAID"} end def get_mint_quote(quote_id) do diff --git a/lib/cashubrew/NUTs/NUT-04/serde.ex b/lib/cashubrew/NUTs/NUT-04/serde.ex index bd62833..4aacb0a 100644 --- a/lib/cashubrew/NUTs/NUT-04/serde.ex +++ b/lib/cashubrew/NUTs/NUT-04/serde.ex @@ -3,6 +3,7 @@ defmodule Cashubrew.Nuts.Nut04.Serde.PostMintQuoteBolt11Request do The body of the post mint quote request """ @enforce_keys [:amount, :unit] + @derive [Jason.Encoder] defstruct [:amount, :unit, :description] end @@ -11,6 +12,7 @@ defmodule Cashubrew.Nuts.Nut04.Serde.PostMintQuoteBolt11Response do The body of the post mint quote response """ @enforce_keys [:quote, :request, :state, :expiry] + @derive [Jason.Encoder] defstruct [:quote, :request, :state, :expiry] end @@ -21,6 +23,7 @@ defmodule Cashubrew.Nuts.Nut04.Serde.PostMintBolt11Request do alias Cashubrew.Nuts.Nut00.BlindedMessage @enforce_keys [:quote, :outputs] + @derive [Jason.Encoder] defstruct [:quote, :outputs] def from_map(map) do diff --git a/lib/cashubrew/lightning/lnd_client.ex b/lib/cashubrew/lightning/lnd_client.ex index a189665..013017a 100644 --- a/lib/cashubrew/lightning/lnd_client.ex +++ b/lib/cashubrew/lightning/lnd_client.ex @@ -5,10 +5,11 @@ defmodule Cashubrew.LightingNetwork.Lnd do use GenServer require Logger - def start_link(arg) do - GenServer.start_link(__MODULE__, arg, name: __MODULE__) + def start_link(_arg) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end + @impl GenServer def init(args) do node_url = URI.parse(System.get_env("LND_URL")) creds = get_creds(System.get_env("LND_CERT")) @@ -33,17 +34,22 @@ defmodule Cashubrew.LightingNetwork.Lnd do def validity, do: 86_400 def create_invoice!(amount, unit, description) do - GenServer.call(__MODULE__, {:create_invoice, amount, unit, description}, __MODULE__) + GenServer.call(__MODULE__, {:create_invoice, amount, unit, description}) end - def handle_call({:create_invoice, amount, unit, description}, _from, state) do + @impl GenServer + def handle_call( + {:create_invoice, amount, unit, description}, + _from, + %{channel: channel, macaroon: macaroon} = state + ) do if unit != "sat" do raise "UnsuportedUnit" end amount_ms = amount * 1000 - expiry = validity() + System.os_time(:second) + expiry = validity() request = %Cashubrew.Lnrpc.Invoice{ memo: description, @@ -51,8 +57,12 @@ defmodule Cashubrew.LightingNetwork.Lnd do expiry: expiry } - {:ok, response} = Cashubrew.Lnrpc.Lightning.Stub.add_invoice(state["channel"], request) - {:reply, response, state} + {:ok, response} = + Cashubrew.Lnrpc.Lightning.Stub.add_invoice(channel, request, + metadata: %{macaroon: macaroon} + ) + + {:reply, {expiry, response}, state} end defp get_creds(cert_path) do diff --git a/lib/cashubrew/schema/mint_quote.ex b/lib/cashubrew/schema/mint_quote.ex index 8bf883a..8885b55 100644 --- a/lib/cashubrew/schema/mint_quote.ex +++ b/lib/cashubrew/schema/mint_quote.ex @@ -7,10 +7,11 @@ defmodule Cashubrew.Schema.MintQuote do @primary_key {:id, :binary_id, autogenerate: false} schema "mint_quotes" do + field(:r_hash, :binary) field(:payment_request, :string) - field(:amount, :integer) - field(:unit, :string) - field(:expiry, :integer) + field(:add_index, :integer) + field(:payment_addr, :binary) + field(:description, :string) # 0 -> "UNPAID", 1 -> "PAID", 2 -> "ISSUED" # The msb is used as a guard against two process minting this quote at the same time. # It has to be set when we start the minting process and cleared in the end, @@ -22,8 +23,16 @@ defmodule Cashubrew.Schema.MintQuote do def changeset(quote, attrs) do quote - |> cast(attrs, [:id, :payment_request, :amount, :unit, :expiry, :state]) - |> validate_required([:id, :payment_request, :amount, :unit, :expiry, :state]) + |> cast(attrs, [ + :id, + :r_hash, + :payment_request, + :add_index, + :payment_addr, + :description, + :state + ]) + |> validate_required([:id, :r_hash, :payment_request, :add_index, :payment_addr, :state]) |> validate_inclusion(:state, [<<0>>, <<1>>, <<2>>, <<128>>, <<129>>, <<130>>]) end diff --git a/lib/cashubrew/web/controllers/mint_controller.ex b/lib/cashubrew/web/controllers/mint_controller.ex index 5b0f522..475b147 100644 --- a/lib/cashubrew/web/controllers/mint_controller.ex +++ b/lib/cashubrew/web/controllers/mint_controller.ex @@ -2,7 +2,6 @@ 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 @@ -50,23 +49,27 @@ defmodule Cashubrew.Web.MintController do e in RuntimeError -> conn |> put_status(:bad_request) |> json(Nut00.Error.new_error(0, e)) end - def create_mint_quote(conn, %{ - "method" => method, - "amount" => amount, - "unit" => unit, - "description" => description - }) do + def create_mint_quote( + conn, + %{ + "method" => method, + "amount" => amount, + "unit" => unit + } = params + ) do if method != "bolt11" do raise "UnsuportedMethod" end + description = Map.get(params, "description") + res = Nut04.Impl.create_mint_quote!(amount, unit, description) json( conn, struct( - Nut04.Serde.PostMintBolt11Response, - Map.merge(res, %{signatures: [], state: "UNPAID"}) + Nut04.Serde.PostMintQuoteBolt11Response, + res ) ) rescue diff --git a/lib/cashubrew/web/router.ex b/lib/cashubrew/web/router.ex index e139fd0..92e3e82 100644 --- a/lib/cashubrew/web/router.ex +++ b/lib/cashubrew/web/router.ex @@ -32,7 +32,6 @@ defmodule Cashubrew.Web.Router do post(Nut03.Routes.v1_swap(), MintController, :swap) # NUT-04 - post(Nut04.Routes.v1_mint_quote(), MintController, :create_mint_quote) get(Nut04.Routes.v1_mint_quote_for_quote_id(), MintController, :get_mint_quote) post(Nut04.Routes.v1_mint(), MintController, :mint_tokens) @@ -41,6 +40,8 @@ defmodule Cashubrew.Web.Router do scope "/", Cashubrew.Web do pipe_through(:api) + # NUT-04 + post(Nut04.Routes.v1_mint_quote(), MintController, :create_mint_quote) # NUT-06 get("/v1/info", MintController, :info) # NUT-05 diff --git a/priv/repo/migrations/20240918113122_create_mint_quote.exs b/priv/repo/migrations/20240918113122_create_mint_quote.exs index f66606b..ca1da1d 100644 --- a/priv/repo/migrations/20240918113122_create_mint_quote.exs +++ b/priv/repo/migrations/20240918113122_create_mint_quote.exs @@ -2,14 +2,14 @@ defmodule Cashubrew.Repo.Migrations.CreateMintQuote do use Ecto.Migration def change do - create table(:mint_quotes) do - add :amount, :integer, null: false + create table(:mint_quotes, primary_key: false) do + add :id, :binary_id, primary_key: true + add :r_hash, :binary, null: false add :payment_request, :text, null: false - add :state, :binary, null: false, default: fragment("decode('00', 'hex')") - add :expiry, :integer, null: false + add :add_index, :integer, null: false + add :payment_addr, :binary, null: false add :description, :string - add :payment_hash, :string - add :unit, :string + add :state, :binary, null: false, default: fragment("decode('00', 'hex')") timestamps() end