Skip to content
This repository has been archived by the owner on Nov 21, 2024. It is now read-only.

Commit

Permalink
feat: create invoice through lnd + tests
Browse files Browse the repository at this point in the history
  • Loading branch information
tdelabro committed Nov 8, 2024
1 parent a0f594a commit a884d16
Show file tree
Hide file tree
Showing 13 changed files with 137 additions and 48 deletions.
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
save-if: false

- name: Setup db
run: mix ecto.setup
run: MIX_ENV=test mix ecto.setup

- name: Integration tests
run: |-
Expand All @@ -82,13 +82,15 @@ 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
until curl http://localhost:4000; do sleep 5; done;
cargo test
cat ../pid | xargs kill
kill $BTCD_AND_LND_SERVERS_PID
Expand Down
9 changes: 8 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down
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
2 changes: 1 addition & 1 deletion integration-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async fn main() {
std::fs::write(
".env",
format!(
"export LND_URL={:?}\nexport LND_CERT={:?}\nexport LND_MACAROON={:?}",
"LND_URL={:?}\nLND_CERT={:?}\nLND_MACAROON={:?}",
lnd.grpc_url,
lnd.tls_cert_path(),
lnd.admin_macaroon_path()
Expand Down
28 changes: 16 additions & 12 deletions lib/cashubrew/NUTs/NUT-04/impl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 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 Down
24 changes: 17 additions & 7 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,26 +34,35 @@ 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
Expand Down
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, :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

Expand Down
21 changes: 12 additions & 9 deletions lib/cashubrew/web/controllers/mint_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/cashubrew/web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions priv/repo/migrations/20240918113122_create_mint_quote.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit a884d16

Please sign in to comment.