Skip to content

Commit

Permalink
feat(test): integration tests for nut04 (#42)
Browse files Browse the repository at this point in the history
Co-authored-by: Timothée Delabrouille <[email protected]>
  • Loading branch information
ybensacq and tdelabro authored Nov 12, 2024
1 parent 24996b5 commit 3e8d9b4
Show file tree
Hide file tree
Showing 16 changed files with 178 additions and 47 deletions.
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,7 @@ jobs:
with:
shared-key: "cache"
save-if: false

- name: Setup db
run: mix ecto.setup
workspaces: integration-tests -> ./integration-tests

- name: Integration tests
run: |-
Expand All @@ -82,16 +80,23 @@ 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 ..
# Setup db
mix ecto.setup
# 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
# Wait until the mix is finished compiling
until curl http://127.0.0.1:4000 > /dev/null; do sleep 5; done;
cd integration-tests
cargo test
# Cleanup
cat ../pid | xargs kill
kill $BTCD_AND_LND_SERVERS_PID
env:
MIX_ENV: dev



Expand Down
4 changes: 3 additions & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ config :cashubrew, Cashubrew.Web.Endpoint,
config :cashubrew, dev_routes: true

# Do not include metadata nor timestamps in development logs
config :logger, :console, format: "[$level] $message\n"
config :logger, :console, format: "[$level] $message\n", lever: :debug

# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
Expand All @@ -44,3 +44,5 @@ config :cashubrew, :repo, Cashubrew.Repo
config :cashubrew, ecto_repos: [Cashubrew.Repo]

config :cashubrew, :lnd_client, Cashubrew.LightingNetwork.Lnd

config :cashubrew, :ssl_verify, :verify_none
2 changes: 2 additions & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ config :cashubrew, Cashubrew.Web.Endpoint, secret_key_base: System.get_env("SECR
config :logger, level: :info

config :cashubrew, :lnd_client, Cashubrew.LightingNetwork.Lnd

config :cashubrew, :ssl_verify, true
16 changes: 15 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
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

ssl_verify =
case config_env() do
:prod -> true
_ -> :verify_none
end

config :cashubrew, :lnd_client, lnd_client
config :cashubrew, :ssl_verify, ssl_verify

if config_env() == :prod do
database_url =
Expand Down
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ else

config :cashubrew, :lnd_client, Cashubrew.LightingNetwork.MockLnd
end

config :cashubrew, :ssl_verify, :verify_none
9 changes: 7 additions & 2 deletions integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
45 changes: 45 additions & 0 deletions integration-tests/nut04.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
4 changes: 2 additions & 2 deletions integration-tests/nut05.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 17 additions & 7 deletions lib/cashubrew/NUTs/NUT-04/impl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,39 @@ defmodule Cashubrew.Nuts.Nut04.Impl do
alias Cashubrew.Nuts.Nut04.Impl.MintQuoteMutex
alias Cashubrew.Schema

require Logger

def create_mint_quote!(amount, unit, description) do
repo = Application.get_env(:cashubrew, :repo)
lnd_client = Application.get_env(:cashubrew, :lnd_client)

{payment_request, _payment_hash} =
Cashubrew.LightingNetwork.Lnd.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 = 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,
expiry: expiry,
add_index: add_index,
payment_addr: payment_addr,
description: description,
# Unpaid
state: <<0>>
})

%{quote_id: quote_id, request: payment_request, expiry: expiry}
%{quote: quote_id, request: payment_request, expiry: expiry, state: "UNPAID"}
end

def get_mint_quote(quote_id) do
Expand Down
4 changes: 4 additions & 0 deletions lib/cashubrew/NUTs/NUT-04/serde.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -36,5 +39,6 @@ defmodule Cashubrew.Nuts.Nut04.Serde.PostMintBolt11Response do
The body of the post mint response
"""
@enforce_keys [:signatures]
@derive [Jason.Encoder]
defstruct [:signatures]
end
28 changes: 19 additions & 9 deletions lib/cashubrew/lightning/lnd_client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -33,33 +34,42 @@ 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,
value_msat: amount_ms,
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
filename = Path.expand(cert_path)

# ++ [verify: true/:verify_none]
ssl_opts = [cacertfile: filename]
verify = Application.get_env(:cashubrew, :ssl_verify)
ssl_opts = [cacertfile: filename, verify: verify]

GRPC.Credential.new(ssl: ssl_opts)
end
Expand Down
10 changes: 10 additions & 0 deletions lib/cashubrew/schema/melt_quote.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,14 @@ defmodule Cashubrew.Schema.MeltQuote do
{:error, changeset} -> raise "Failed to insert key: #{inspect(changeset.errors)}"
end
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
19 changes: 14 additions & 5 deletions lib/cashubrew/schema/mint_quote.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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, :amout, :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

Expand Down
31 changes: 21 additions & 10 deletions lib/cashubrew/web/controllers/mint_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,34 @@ 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, params) do
method = params["method"]

def create_mint_quote(
conn,
%{
"method" => method,
"amount" => amount,
"unit" => unit
} = params
) do
if method != "bolt11" do
raise "UnsuportedMethod"
end

%Nut04.Serde.PostMintQuoteBolt11Request{
amount: amount,
unit: unit,
description: description
} = params["body"]
description = Map.get(params, "description")

res = Nut04.Impl.create_mint_quote!(amount, unit, description)
json(conn, struct(Nut04.Serde.PostMintBolt11Response, Map.put(res, :state, "UNPAID")))

json(
conn,
struct(
Nut04.Serde.PostMintQuoteBolt11Response,
res
)
)
rescue
e in RuntimeError -> conn |> put_status(:bad_request) |> json(Nut00.Error.new_error(0, e))
e in RuntimeError ->
conn
|> put_status(:bad_request)
|> json(%{error: e.message})
end

def get_mint_quote(conn, %{"quote_id" => quote_id, "method" => method}) do
Expand Down
Loading

0 comments on commit 3e8d9b4

Please sign in to comment.