├── VERSION ├── internal ├── routes │ ├── utils.go │ ├── admin │ │ ├── static │ │ │ ├── .gitignore │ │ │ ├── login.css │ │ │ ├── src │ │ │ │ ├── modules │ │ │ │ │ ├── core.js │ │ │ │ │ └── auth.js │ │ │ │ └── index.js │ │ │ ├── package.json │ │ │ ├── card.css │ │ │ ├── summary.css │ │ │ ├── layout.css │ │ │ ├── header.css │ │ │ ├── button.css │ │ │ ├── expansion.css │ │ │ ├── app.js │ │ │ ├── settings.css │ │ │ └── app.css │ │ ├── templates │ │ │ ├── utils.go │ │ │ ├── general.templ │ │ │ ├── layout.templ │ │ │ ├── header.templ │ │ │ ├── lightning_activity.templ │ │ │ ├── login.templ │ │ │ ├── components.templ │ │ │ └── keysets.templ │ │ ├── tabs_test.go │ │ ├── lightning.go │ │ ├── token_blacklist.go │ │ ├── logout.go │ │ ├── mint-activity.go │ │ └── keysets.go │ ├── routes.go │ ├── middleware │ │ ├── cache.go │ │ └── auth.go │ └── auth.go ├── database │ ├── goose │ │ ├── migrations │ │ │ ├── 2_add_y_to_proof.sql │ │ │ ├── 3_add_version_to_seeds.sql │ │ │ ├── 25_index_y_proofs_table.sql │ │ │ ├── 4_add_unit_to_request.sql │ │ │ ├── 35_add_final_expiry_to_seeds.sql │ │ │ ├── 31_add_pubkey_to_mint_quote.sql │ │ │ ├── 26_add_amount_to_mint_request.sql │ │ │ ├── 11_add_fees_to_keyset.sql │ │ │ ├── 14_add_mpp_melt_request.sql │ │ │ ├── 18_add_quote_reference_to_proofs.sql │ │ │ ├── 15_fix_restore_table_remove_witness.sql │ │ │ ├── 33_add_description_to_mint_request.sql │ │ │ ├── 8_add_encrypted_field_to_seeds.sql │ │ │ ├── 10_add_preimage_to_melt_request.sql │ │ │ ├── 32_remove_foreigh_key_seed_ref.sql │ │ │ ├── 24_add_paid_fee_to_melt_request.sql │ │ │ ├── 16_remove_seed_and_encrypted.sql │ │ │ ├── 7_add_witness_data.sql │ │ │ ├── 6_wallet_recovery.sql │ │ │ ├── 9_add_state_field_to_melt_mint_request.sql │ │ │ ├── 17_add_proofs_state.sql │ │ │ ├── 20_add_dleq_to_recovery.sql │ │ │ ├── 23_create_melt_change_table.sql │ │ │ ├── 22_liquidity_swap_request.sql │ │ │ ├── 12_add_nostr_login_table.sql │ │ │ ├── 21_add_unique_constraint.sql │ │ │ ├── 27_add_strike_key.sql │ │ │ ├── 13_add_seen_at_fields.sql │ │ │ ├── 34_add_icon_url_and_tos_url_to_config.sql │ │ │ ├── 30_add_user_auth_table.sql │ │ │ ├── 5_changed_payed_to_request_paid_and_addpayed_field.sql │ │ │ ├── 28_add_checking_id.sql │ │ │ ├── 19_mint_config_transition.sql │ │ │ ├── 29_auth_config_fields.sql │ │ │ └── 1_create_baseline.sql │ │ └── goose.go │ ├── mock_db │ │ ├── config.go │ │ ├── change.go │ │ ├── auth.go │ │ └── admin.go │ └── postgresql │ │ ├── auth.go │ │ └── change.go ├── utils │ ├── version.go │ ├── liquidityManager.go │ ├── proofs_test.go │ └── common.go ├── mint │ ├── oidc.go │ ├── seeds.go │ ├── websocket_test.go │ ├── proofs.go │ ├── config.go │ ├── bolt11.go │ ├── auth.go │ └── websocket.go ├── signer │ ├── interface.go │ ├── utils_test.go │ ├── types.go │ ├── remote_signer │ │ └── util.go │ ├── utils.go │ └── local_signer │ │ └── local_signer_test.go ├── lightning │ ├── utils.go │ ├── backend.go │ ├── lightning_test.go │ ├── proto │ │ └── cln_primitives.proto │ ├── invoice.go │ └── fake_wallet.go └── gen │ └── signer.proto ├── .goosehints ├── .gitignore ├── api └── cashu │ ├── erros_test.go │ ├── websocket.go │ ├── util_test.go │ ├── util.go │ ├── keys.go │ ├── swap.go │ ├── auth.go │ ├── melt_test.go │ └── errors.go ├── AGENTS.md ├── .github └── workflows │ ├── docker-build-test.yml │ ├── release-from-version.yml │ ├── docker-publish-latest.yml │ ├── docker-publish-on-tag.yml │ └── workflow.yml ├── LICENSE ├── .air.toml ├── test ├── configTest │ ├── setup.go │ └── main_test.go └── setupTest │ └── lnd_test.go ├── env.example ├── Dockerfile ├── docker-compose-dev.yml ├── test_calls.txt ├── cmd └── nutmix │ ├── info_test.go │ └── main_cache_test.go └── pkg └── crypto └── bdhke.go /VERSION: -------------------------------------------------------------------------------- 1 | 0.3.9 2 | -------------------------------------------------------------------------------- /internal/routes/utils.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import "log/syslog" 4 | 5 | type Logger struct { 6 | Sysloger *syslog.Writer 7 | } 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/2_add_y_to_proof.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | ALTER TABLE proofs ADD Y TEXT; 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE proofs DROP COLUMN Y 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/3_add_version_to_seeds.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE seeds ADD version INT; 3 | 4 | 5 | -- +goose Down 6 | ALTER TABLE seeds DROP COLUMN version 7 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/25_index_y_proofs_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE INDEX IF NOT EXISTS idx_proofs_y ON proofs (y); 3 | 4 | 5 | -- +goose Down 6 | DROP INDEX idx_proofs_y; 7 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/4_add_unit_to_request.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | ALTER TABLE mint_request ADD unit TEXT; 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE mint_request DROP COLUMN unit; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/35_add_final_expiry_to_seeds.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE seeds ADD COLUMN final_expiry int4; 3 | 4 | -- +goose Down 5 | ALTER TABLE seeds DROP COLUMN final_expiry; 6 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/31_add_pubkey_to_mint_quote.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request ADD pubkey bytea; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE mint_request DROP COLUMN pubkey; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/26_add_amount_to_mint_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request ADD amount int4; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE mint_request DROP COLUMN amount; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/11_add_fees_to_keyset.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE seeds ADD input_fee_ppk int NOT NULL DEFAULT 0; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE seeds DROP COLUMN input_fee_ppk; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/14_add_mpp_melt_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE melt_request ADD mpp bool NOT NULL DEFAULT false; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE melt_request DROP COLUMN mpp; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/18_add_quote_reference_to_proofs.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE proofs 3 | ADD COLUMN quote text; 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE proofs 8 | DROP COLUMN quote; 9 | 10 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/15_fix_restore_table_remove_witness.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE recovery_signature DROP COLUMN witness; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE recovery_signature ADD witness TEXT; -------------------------------------------------------------------------------- /internal/database/goose/migrations/33_add_description_to_mint_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request ADD description text; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE mint_request DROP COLUMN description; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/8_add_encrypted_field_to_seeds.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE seeds ADD encrypted BOOL NOT NULL DEFAULT FALSE; 3 | 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE seeds DROP COLUMN encrypted; 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/10_add_preimage_to_melt_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE melt_request ADD payment_preimage TEXT; 3 | 4 | 5 | -- +goose Down 6 | ALTER TABLE melt_request DROP COLUMN payment_preimage; 7 | 8 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/32_remove_foreigh_key_seed_ref.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE proofs DROP CONSTRAINT proofs_seeds_fk; 3 | 4 | -- +goose Down 5 | ALTER TABLE proofs ADD CONSTRAINT proofs_seeds_fk FOREIGN KEY (id) REFERENCES seeds(id) -------------------------------------------------------------------------------- /internal/routes/admin/static/.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js/npm build artifacts 2 | dist/ 3 | 4 | # npm dependencies 5 | node_modules/ 6 | 7 | # IDE files 8 | .vscode/ 9 | .idea/ 10 | *.swp 11 | *.swo 12 | 13 | # OS generated files 14 | .DS_Store 15 | Thumbs.db 16 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/24_add_paid_fee_to_melt_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE melt_request ADD fee_paid int8; 3 | UPDATE melt_request SET fee_paid = 0 WHERE fee_paid IS NULL; 4 | 5 | 6 | 7 | -- +goose Down 8 | ALTER TABLE melt_request DROP COLUMN fee_paid; 9 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/16_remove_seed_and_encrypted.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE seeds 3 | DROP COLUMN seed, 4 | DROP COLUMN encrypted; 5 | 6 | 7 | 8 | -- +goose Down 9 | ALTER TABLE seeds 10 | ADD seed bytea NOT NULL, 11 | ADD encrypted BOOL NOT NULL DEFAULT FALSE; 12 | -------------------------------------------------------------------------------- /internal/utils/version.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | var ( 4 | // AppVersion will be in 0.0.0 format 5 | AppVersion = "development" 6 | 7 | // BuildTime is the time it built, in RFC3339 8 | BuildTime = "unknown" 9 | 10 | // GitCommit is the Git commit hash 11 | GitCommit = "unknown" 12 | ) 13 | -------------------------------------------------------------------------------- /.goosehints: -------------------------------------------------------------------------------- 1 | This is a GO application that uses the GIN web framework [gin docs](https://gin-gonic.com/en/docs/). 2 | 3 | The golang application has an administrative dashboard that uses templ. View documentation: [templ docs](https://templ.guide/) for html templating. 4 | 5 | Never commit to code to git. 6 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/7_add_witness_data.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE proofs ADD witness TEXT; 3 | ALTER TABLE recovery_signature ADD witness TEXT; 4 | 5 | 6 | 7 | -- +goose Down 8 | ALTER TABLE proofs DROP COLUMN witness; 9 | ALTER TABLE recovery_signature DROP COLUMN witness; 10 | -------------------------------------------------------------------------------- /internal/routes/admin/static/login.css: -------------------------------------------------------------------------------- 1 | .center-content-login { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | 7 | /* Login card max-width for better visual balance */ 8 | .main-content-full .card { 9 | max-width: 500px; 10 | width: 100%; 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | build-errors.log 3 | mint/tmp/ 4 | mint/main 5 | env/ 6 | tmp/ 7 | *_templ.go 8 | *_templ.txt 9 | *_gen.go 10 | *.pb.go 11 | 12 | tls/ 13 | 14 | build/ 15 | 16 | keycloak/keycloak_export 17 | keycloak/postgres_data 18 | 19 | .cursor/ 20 | /nutmix 21 | 22 | dist 23 | release/ 24 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/6_wallet_recovery.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE "recovery_signature" ( 3 | amount int4 NULL, 4 | id text NOT NULL, 5 | "B_" text NOT NULL, 6 | "C_" text NOT NULL, 7 | created_at int8 NOT NULL 8 | ); 9 | 10 | 11 | -- +goose Down 12 | DROP TABLE "recovery_signature"; 13 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/9_add_state_field_to_melt_mint_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request ADD state TEXT; 3 | ALTER TABLE melt_request ADD state TEXT; 4 | 5 | 6 | -- +goose Down 7 | ALTER TABLE mint_request DROP COLUMN state; 8 | ALTER TABLE melt_request DROP COLUMN state; 9 | 10 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/17_add_proofs_state.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE proofs ADD state TEXT; 3 | 4 | -- if the proofs have null value on state asume they are spent 5 | UPDATE proofs 6 | SET state = 'SPENT' 7 | WHERE state IS NULL; 8 | 9 | 10 | -- +goose Down 11 | ALTER TABLE proofs DROP COLUMN state 12 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/20_add_dleq_to_recovery.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE recovery_signature ADD dleq_e text; 3 | ALTER TABLE recovery_signature ADD dleq_s text; 4 | 5 | 6 | 7 | -- +goose Down 8 | ALTER TABLE recovery_signature DROP COLUMN dleq_e; 9 | ALTER TABLE recovery_signature DROP COLUMN dleq_s; 10 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/23_create_melt_change_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE IF NOT EXISTS "melt_change_message" ( 3 | "B_" text NOT NULL, 4 | created_at int8 NOT NULL, 5 | quote text NOT NULL, 6 | id text NOT NULL 7 | ); 8 | 9 | 10 | -- +goose Down 11 | DROP TABLE "melt_change_message"; 12 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/22_liquidity_swap_request.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE liquidity_swaps( 3 | amount INTEGER, 4 | id TEXT, 5 | state TEXT, 6 | type TEXT, 7 | expiration int4 NOT NULL, 8 | lightning_invoice TEXT NOT NULL 9 | ); 10 | 11 | 12 | -- +goose Down 13 | DROP TABLE IF EXISTS liquidity_swaps; 14 | -------------------------------------------------------------------------------- /internal/mint/oidc.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/coreos/go-oidc/v3/oidc" 7 | ) 8 | 9 | func (m *Mint) SetupOidcService(ctx context.Context, url string) error { 10 | oidcClient, err := oidc.NewProvider(ctx, url) 11 | if err != nil { 12 | return err 13 | } 14 | 15 | m.OICDClient = oidcClient 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/utils.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "golang.org/x/text/language" 5 | "golang.org/x/text/message" 6 | ) 7 | 8 | // formatNumber formats a number with thousand separators (periods) 9 | func FormatNumber(n uint64) string { 10 | p := message.NewPrinter(language.German) 11 | return p.Sprintf("%.0f", float64(n)) 12 | } 13 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/general.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | templ ErrorNotif(message string) { 4 |
5 | { message } 6 |
7 | } 8 | 9 | templ SuccessNotif(message string) { 10 |
11 | { message } 12 |
13 | } 14 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/12_add_nostr_login_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE "nostr_login" ( 3 | nonce text NOT NULL, 4 | expiry int8 NOT NULL, 5 | activated bool NOT NULL, 6 | CONSTRAINT nostr_login_pk PRIMARY KEY (nonce), 7 | CONSTRAINT nostr_login_unique UNIQUE (nonce) 8 | ); 9 | 10 | 11 | 12 | -- +goose Down 13 | DROP TABLE IF EXISTS nostr_login; 14 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/21_add_unique_constraint.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE proofs ADD CONSTRAINT unique_y UNIQUE (secret, y); 3 | ALTER TABLE recovery_signature ADD CONSTRAINT unique_recovery_B_ UNIQUE ("B_"); 4 | 5 | 6 | 7 | -- +goose Down 8 | ALTER TABLE proofs DROP CONSTRAINT unique_y; 9 | ALTER TABLE recovery_signature DROP CONSTRAINT unique_recovery_B_; 10 | 11 | -------------------------------------------------------------------------------- /api/cashu/erros_test.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import "testing" 4 | 5 | func TestCreatingAnErrorResponse(t *testing.T) { 6 | 7 | response := ErrorCodeToResponse(INSUFICIENT_FEE, nil) 8 | 9 | if response.Code != 11006 { 10 | t.Errorf("Did not get the correct error node.") 11 | } 12 | 13 | if response.Error != "Insufficient fee" { 14 | t.Errorf("Incorrect error string") 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /internal/routes/admin/static/src/modules/core.js: -------------------------------------------------------------------------------- 1 | // Core HTMX initialization 2 | import htmx from 'htmx.org'; 3 | import 'htmx-ext-preload'; 4 | import 'htmx-ext-remove-me'; 5 | 6 | /** 7 | * Initialize HTMX and make it globally available 8 | */ 9 | export function initCore() { 10 | // Make HTMX available globally for use in templates and other scripts 11 | window.htmx = htmx; 12 | console.log('HTMX initialized'); 13 | } 14 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/27_add_strike_key.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE config ADD strike_key text; 3 | ALTER TABLE config ADD strike_endpoint text; 4 | UPDATE config SET strike_key = '' WHERE strike_key IS NULL; 5 | UPDATE config SET strike_endpoint = '' WHERE strike_endpoint IS NULL; 6 | 7 | 8 | 9 | -- +goose Down 10 | ALTER TABLE config DROP COLUMN strike_key; 11 | ALTER TABLE config DROP COLUMN strike_endpoint; 12 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/13_add_seen_at_fields.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request ADD seen_at int NOT NULL DEFAULT 0; 3 | ALTER TABLE melt_request ADD seen_at int NOT NULL DEFAULT 0; 4 | ALTER TABLE proofs ADD seen_at int NOT NULL DEFAULT 0; 5 | 6 | 7 | 8 | -- +goose Down 9 | ALTER TABLE mint_request DROP COLUMN seen_at; 10 | ALTER TABLE melt_request DROP COLUMN seen_at; 11 | ALTER TABLE proofs DROP COLUMN seen_at; 12 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/34_add_icon_url_and_tos_url_to_config.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- SQL in this section is executed when the migration is applied. 3 | ALTER TABLE config ADD COLUMN icon_url TEXT; 4 | ALTER TABLE config ADD COLUMN tos_url TEXT; 5 | 6 | -- +goose Down 7 | -- SQL in this section is executed when the migration is rolled back. 8 | ALTER TABLE config DROP COLUMN IF EXISTS icon_url; 9 | ALTER TABLE config DROP COLUMN IF EXISTS tos_url; 10 | -------------------------------------------------------------------------------- /internal/database/mock_db/config.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "github.com/lescuer97/nutmix/internal/utils" 5 | ) 6 | 7 | func (pql *MockDB) GetConfig() (utils.Config, error) { 8 | return pql.Config, nil 9 | } 10 | 11 | func (pql *MockDB) SetConfig(config utils.Config) error { 12 | pql.Config = config 13 | return nil 14 | } 15 | 16 | func (pql *MockDB) UpdateConfig(config utils.Config) error { 17 | pql.Config = config 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/30_add_user_auth_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE IF NOT EXISTS user_auth ( 3 | sub text NOT NULL, 4 | aud text, 5 | last_logged_in int4 NOT NULL DEFAULT 0, 6 | CONSTRAINT user_auth_pk PRIMARY KEY (sub), 7 | CONSTRAINT user_auth_unique UNIQUE (sub) 8 | ); 9 | CREATE INDEX IF NOT EXISTS idx_user_auth_sub ON user_auth (sub); 10 | 11 | -- +goose Down 12 | DROP TABLE IF EXISTS user_auth; 13 | DROP INDEX idx_user_auth_sub; 14 | -------------------------------------------------------------------------------- /internal/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/lescuer97/nutmix/internal/mint" 6 | "github.com/lescuer97/nutmix/internal/routes/middleware" 7 | ) 8 | 9 | func V1Routes(r *gin.Engine, mint *mint.Mint) { 10 | r.Use(middleware.ClearAuthMiddleware(mint)) 11 | r.Use(middleware.BlindAuthMiddleware(mint)) 12 | v1AuthRoutes(r, mint) 13 | v1MintRoutes(r, mint) 14 | v1bolt11Routes(r, mint) 15 | v1WebSocketRoute(r, mint) 16 | 17 | } 18 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | ## Development instructions 2 | 3 | - After finishing a task try compiling the code. 4 | - Don't leave tasks half way. 5 | - Never commit or merge code using git. 6 | - Never add dependencies unless specifically authorized. 7 | - if you don't know how to do something don't guess. It's okey if you don't know. 8 | - Before you change anything review this proposal critically. 9 | 10 | When scanning source files, use repomix to save space and tokens. 11 | Use the Context7 MCP server to know the latest way to use libraries like `gin` and `templ`. 12 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/5_changed_payed_to_request_paid_and_addpayed_field.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request RENAME COLUMN paid TO request_paid; 3 | ALTER TABLE melt_request RENAME COLUMN paid TO request_paid; 4 | ALTER TABLE mint_request ADD minted BOOLEAN NOT NULL DEFAULT FALSE; 5 | ALTER TABLE melt_request ADD melted BOOLEAN NOT NULL DEFAULT FALSE; 6 | 7 | 8 | -- +goose Down 9 | ALTER TABLE mint_request RENAME COLUMN request_paid TO paid; 10 | ALTER TABLE melt_request RENAME COLUMN request_paid TO paid; 11 | ALTER TABLE mint_request DROP COLUMN minted; 12 | ALTER TABLE melt_request DROP COLUMN melted; 13 | 14 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/28_add_checking_id.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE mint_request ADD checking_id text; 3 | ALTER TABLE melt_request ADD checking_id text; 4 | ALTER TABLE liquidity_swaps ADD checking_id text; 5 | UPDATE mint_request SET checking_id = '' WHERE checking_id IS NULL; 6 | UPDATE melt_request SET checking_id = '' WHERE checking_id IS NULL; 7 | UPDATE liquidity_swaps SET checking_id = '' WHERE checking_id IS NULL; 8 | 9 | 10 | 11 | -- +goose Down 12 | ALTER TABLE mint_request DROP COLUMN checking_id; 13 | ALTER TABLE melt_request DROP COLUMN checking_id; 14 | ALTER TABLE liquidity_swaps DROP COLUMN checking_id; 15 | -------------------------------------------------------------------------------- /internal/routes/admin/tabs_test.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCheckIntegerFromStringSuccess(t *testing.T) { 8 | text := "2" 9 | int, err := checkLimitSat(text) 10 | 11 | if err != nil { 12 | t.Error("Check limit should have work") 13 | } 14 | 15 | success := 2 16 | if *int != success { 17 | t.Error("Convertion should have occured") 18 | } 19 | } 20 | 21 | func TestCheckIntegerFromStringFailureBool(t *testing.T) { 22 | text := "2.2" 23 | _, err := checkLimitSat(text) 24 | 25 | if err == nil { 26 | t.Error("Check limit should have failed. Because it should not allow float") 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/layout.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | templ head() { 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | } 12 | 13 | templ Layout(route string) { 14 | 15 | 16 | @head() 17 | @Header() 18 | 19 |
20 | { children... } 21 | 22 | 23 | } 24 | -------------------------------------------------------------------------------- /internal/routes/admin/lightning.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/gin-gonic/gin" 8 | m "github.com/lescuer97/nutmix/internal/mint" 9 | "github.com/lescuer97/nutmix/internal/routes/admin/templates" 10 | ) 11 | 12 | func LightningDataFormFields(mint *m.Mint) gin.HandlerFunc { 13 | return func(c *gin.Context) { 14 | 15 | backend := c.Query(m.MINT_LIGHTNING_BACKEND_ENV) 16 | 17 | ctx := context.Background() 18 | err := templates.SetupForms(backend, mint.Config).Render(ctx, c.Writer) 19 | 20 | if err != nil { 21 | _ = c.Error(fmt.Errorf("templates.SetupForms(mint.Config).Render(ctx, c.Writer). %w", err)) 22 | return 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/signer/interface.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import "github.com/lescuer97/nutmix/api/cashu" 4 | 5 | type Signer interface { 6 | GetKeysets() (GetKeysetsResponse, error) 7 | GetKeysById(id string) (GetKeysResponse, error) 8 | GetActiveKeys() (GetKeysResponse, error) 9 | 10 | GetAuthKeys() (GetKeysetsResponse, error) 11 | GetAuthKeysById(id string) (GetKeysResponse, error) 12 | GetAuthActiveKeys() (GetKeysResponse, error) 13 | 14 | RotateKeyset(unit cashu.Unit, fee uint, expiry_limit uint) error 15 | GetSignerPubkey() (string, error) 16 | 17 | SignBlindMessages(messages []cashu.BlindedMessage) ([]cashu.BlindSignature, []cashu.RecoverSigDB, error) 18 | VerifyProofs(proofs []cashu.Proof) error 19 | } 20 | -------------------------------------------------------------------------------- /internal/database/goose/goose.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "fmt" 7 | 8 | "github.com/pressly/goose/v3" 9 | ) 10 | 11 | type DatabaseType string 12 | 13 | const POSTGRES DatabaseType = "postgres" 14 | 15 | //go:embed migrations/*.sql 16 | var embedMigrations embed.FS // 17 | 18 | func RunMigration(db *sql.DB, databaseType DatabaseType) error { 19 | 20 | goose.SetBaseFS(embedMigrations) 21 | if err := goose.SetDialect(string(databaseType)); err != nil { 22 | return fmt.Errorf(`goose.SetDialect(string(databaseType)). %w`, err) 23 | } 24 | 25 | if err := goose.Up(db, "migrations"); err != nil { 26 | return fmt.Errorf(`goose.Up(db, "migrations"). %w`, err) 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/routes/admin/static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-static", 3 | "version": "", 4 | "description": "Frontend static assets for Nutmix Admin Dashboard", 5 | "main": "app.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "just web-build-prod", 9 | "dev": "just web-build-dev", 10 | "clean": "rm -rf dist/*" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "esbuild": "^0.24.0" 17 | }, 18 | "dependencies": { 19 | "chart.js": "^4.4.0", 20 | "chartjs-adapter-date-fns": "^3.0.0", 21 | "date-fns": "^3.6.0", 22 | "htmx.org": "^2.0.8", 23 | "htmx-ext-preload": "^2.1.2", 24 | "htmx-ext-remove-me": "^2.0.2" 25 | }, 26 | "packageManager": "pnpm@9.0.0+" 27 | } 28 | -------------------------------------------------------------------------------- /internal/routes/admin/static/src/index.js: -------------------------------------------------------------------------------- 1 | // Main entry point - imports and initializes all modules in correct order 2 | 3 | // 1. Core HTMX setup - must be first 4 | import { initCore } from './modules/core.js'; 5 | 6 | // 2. Authentication module 7 | import { initAuth } from './modules/auth.js'; 8 | 9 | 10 | /** 11 | * Initialize the application 12 | * Called when DOM is ready 13 | */ 14 | function initializeApp() { 15 | // Initialize core (HTMX and extensions) 16 | initCore(); 17 | 18 | // Initialize authentication handlers 19 | initAuth(); 20 | } 21 | 22 | // Wait for DOM to be ready before initializing 23 | if (document.readyState === 'loading') { 24 | document.addEventListener('DOMContentLoaded', initializeApp); 25 | } else { 26 | // DOM is already ready 27 | initializeApp(); 28 | } 29 | -------------------------------------------------------------------------------- /internal/signer/utils_test.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lescuer97/nutmix/api/cashu" 7 | "github.com/tyler-smith/go-bip32" 8 | ) 9 | 10 | func TestGeneratingAuthKeyset(t *testing.T) { 11 | // setup key 12 | key, err := bip32.NewMasterKey([]byte("seed")) 13 | if err != nil { 14 | t.Errorf("could not setup master key %+v", err) 15 | } 16 | Seed := cashu.Seed{Version: 1, Unit: cashu.AUTH.String()} 17 | 18 | generatedKeysets, err := DeriveKeyset(key, Seed) 19 | if err != nil { 20 | t.Errorf("Error deriving keyset: %+v", err) 21 | } 22 | 23 | if len(generatedKeysets) != 1 { 24 | t.Errorf("There shouls only be 1 keyset for auth") 25 | } 26 | if generatedKeysets[0].Amount != 1 { 27 | t.Errorf("Value should be 1. %v", generatedKeysets[0].Amount) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/signer/types.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/lescuer97/nutmix/api/cashu" 7 | ) 8 | 9 | var ErrNoKeysetFound = errors.New("no keyset found") 10 | 11 | type GetKeysResponse struct { 12 | Keysets []KeysetResponse `json:"keysets"` 13 | } 14 | type GetKeysetsResponse struct { 15 | Keysets []cashu.BasicKeysetResponse `json:"keysets"` 16 | } 17 | 18 | type KeysetResponse struct { 19 | Id string `json:"id"` 20 | Unit string `json:"unit"` 21 | Keys map[uint64]string `json:"keys"` 22 | InputFeePpk uint `json:"input_fee_ppk"` 23 | } 24 | 25 | type BasicKeysetResponse struct { 26 | Id string `json:"id"` 27 | Unit string `json:"unit"` 28 | Active bool `json:"active"` 29 | InputFeePpk uint `json:"input_fee_ppk"` 30 | } 31 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/header.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | templ Header() { 4 |
5 |

6 | dashboard 7 |

8 | 17 |
18 | } 19 | -------------------------------------------------------------------------------- /internal/lightning/utils.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/lightningnetwork/lnd/lnrpc" 7 | ) 8 | 9 | const ( 10 | MAINNET = "mainnet" 11 | REGTEST = "regtest" 12 | TESTNET = "testnet" 13 | TESTNET3 = "testnet3" 14 | SIGNET = "signet" 15 | ) 16 | 17 | const MinimumLightningFee float64 = 0.01 18 | 19 | func GetAverageRouteFee(routes []*lnrpc.Route) uint64 { 20 | var fees uint64 21 | var amount_routes uint64 22 | 23 | for _, route := range routes { 24 | fees += uint64(route.TotalFeesMsat) 25 | amount_routes += 1 26 | } 27 | return fees / amount_routes 28 | } 29 | 30 | func GetFeeReserve(invoiceSatAmount uint64, queriedFee uint64) uint64 { 31 | invoiceMinFee := float64(invoiceSatAmount) * MinimumLightningFee 32 | 33 | fee := uint64(math.Max(invoiceMinFee, float64(queriedFee))) 34 | return fee 35 | } 36 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/19_mint_config_transition.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE config ( 3 | id INT DEFAULT 1, 4 | name TEXT, 5 | description TEXT, 6 | description_long TEXT, 7 | motd TEXT, 8 | email TEXT, 9 | nostr TEXT, 10 | network TEXT, 11 | mint_lightning_backend TEXT, 12 | lnd_grpc_host TEXT, 13 | lnd_tls_cert TEXT, 14 | lnd_macaroon TEXT, 15 | mint_lnbits_endpoint TEXT, 16 | mint_lnbits_key TEXT, 17 | cln_grpc_host TEXT, 18 | cln_ca_cert TEXT, 19 | cln_client_cert TEXT, 20 | cln_client_key TEXT, 21 | cln_macaroon TEXT, 22 | 23 | peg_out_only BOOLEAN DEFAULT FALSE, 24 | peg_out_limit_sats INTEGER, 25 | peg_in_limit_sats INTEGER, 26 | 27 | CONSTRAINT single_row CHECK (id = 1), 28 | CONSTRAINT config_id_pk PRIMARY KEY (id) 29 | ); 30 | 31 | 32 | -- +goose Down 33 | DROP TABLE IF EXISTS config; 34 | -------------------------------------------------------------------------------- /internal/mint/seeds.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "github.com/lescuer97/nutmix/api/cashu" 5 | ) 6 | 7 | type SeedType struct { 8 | Version int 9 | Active bool 10 | Unit cashu.Unit 11 | } 12 | 13 | func CheckForInactiveSeeds(seeds []cashu.Seed) ([]SeedType, error) { 14 | 15 | seedTypes := make(map[cashu.Unit]SeedType) 16 | 17 | for _, seed := range seeds { 18 | unit, err := cashu.UnitFromString(seed.Unit) 19 | 20 | if err != nil { 21 | return nil, err 22 | } 23 | if seed.Version > seedTypes[unit].Version { 24 | 25 | seedTypes[unit] = SeedType{ 26 | Version: seed.Version, 27 | Active: seed.Active, 28 | Unit: unit, 29 | } 30 | } 31 | 32 | } 33 | inactiveSeeds := make([]SeedType, 0) 34 | 35 | for _, seedType := range seedTypes { 36 | if !seedType.Active { 37 | inactiveSeeds = append(inactiveSeeds, seedType) 38 | } 39 | } 40 | 41 | return inactiveSeeds, nil 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-test.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build Test 2 | 3 | on: 4 | pull_request: 5 | branches: [master, main] 6 | push: 7 | branches: [master, main] 8 | 9 | jobs: 10 | test-docker-build: 11 | name: Test Docker Build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v5 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | 21 | - name: Build Docker image 22 | uses: docker/build-push-action@v6 23 | with: 24 | context: . 25 | file: ./Dockerfile 26 | push: false 27 | platforms: linux/amd64 28 | tags: nutmix:test 29 | cache-from: type=gha 30 | cache-to: type=gha,mode=max 31 | 32 | - name: Verify build succeeded 33 | run: | 34 | echo "Docker build completed successfully" 35 | docker images nutmix:test 36 | 37 | -------------------------------------------------------------------------------- /internal/mint/websocket_test.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lescuer97/nutmix/api/cashu" 7 | ) 8 | 9 | func TestDeleteSubIdKeepOther(t *testing.T) { 10 | observer := Observer{} 11 | observer.Proofs = make(map[string][]ProofWatchChannel) 12 | 13 | proofChan1 := make(chan cashu.Proof) 14 | proofChan2 := make(chan cashu.Proof) 15 | sub1 := ProofWatchChannel{ 16 | SubId: "1", 17 | Channel: proofChan1, 18 | } 19 | sub2 := ProofWatchChannel{ 20 | SubId: "2", 21 | Channel: proofChan2, 22 | } 23 | observer.AddProofWatch("test", sub1) 24 | observer.AddProofWatch("test", sub2) 25 | proofs := observer.Proofs["test"] 26 | if proofs[0].SubId != "1" { 27 | t.Errorf("\n Sub id is incorrect. %v", proofs[0]) 28 | } 29 | if proofs[1].SubId != "2" { 30 | t.Errorf("\n Sub id is incorrect. %v", proofs[1]) 31 | } 32 | observer.RemoveWatch("1") 33 | 34 | if proofs[0].SubId != "2" { 35 | t.Errorf("\n didn't remove proof correctly. %v", proofs[0]) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/29_auth_config_fields.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | ALTER TABLE config ADD mint_require_auth boolean DEFAULT FALSE; 3 | ALTER TABLE config ADD mint_auth_oicd_url text DEFAULT ''; 4 | ALTER TABLE config ADD mint_auth_oicd_client_id text DEFAULT ''; 5 | ALTER TABLE config ADD mint_auth_rate_limit_per_minute int4 default 5; 6 | ALTER TABLE config ADD mint_auth_max_blind_tokens int4 default 100; 7 | ALTER TABLE config ADD mint_auth_clear_auth_urls text[] default '{}'; 8 | ALTER TABLE config ADD mint_auth_blind_auth_urls text[] default '{}'; 9 | 10 | 11 | 12 | -- +goose Down 13 | ALTER TABLE config DROP COLUMN mint_require_auth; 14 | ALTER TABLE config DROP COLUMN mint_auth_oicd_url; 15 | ALTER TABLE config DROP COLUMN mint_auth_oicd_client_id; 16 | ALTER TABLE config DROP COLUMN mint_auth_rate_limit_per_minute; 17 | ALTER TABLE config DROP COLUMN mint_auth_max_blind_tokens; 18 | ALTER TABLE config DROP COLUMN mint_auth_clear_auth_urls; 19 | ALTER TABLE config DROP COLUMN mint_auth_blind_auth_urls; 20 | 21 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/lightning_activity.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import "github.com/lescuer97/nutmix/internal/utils" 4 | 5 | templ LightningActivityLayout(config utils.Config, selectedRange string) { 6 | @Layout("lightning") { 7 |
8 | @TimeRangeSelector(selectedRange) 9 |
18 |
19 |
20 | 21 | Loading chart... 22 |
23 |
24 |
25 |
26 | @ExpansionPanel("Connection Settings", "Configure Lightning node connection", nil) { 27 | @LightningConnectionSettings(config) 28 | } 29 |
30 |
31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/database/mock_db/change.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "github.com/jackc/pgx/v5" 5 | "github.com/lescuer97/nutmix/api/cashu" 6 | ) 7 | 8 | func (m *MockDB) SaveMeltChange(tx pgx.Tx, change []cashu.BlindedMessage, quote string) error { 9 | 10 | for _, v := range change { 11 | m.MeltChange = append(m.MeltChange, cashu.MeltChange{ 12 | B_: v.B_, 13 | Id: v.Id, 14 | Quote: quote, 15 | }) 16 | 17 | } 18 | return nil 19 | } 20 | func (m *MockDB) GetMeltChangeByQuote(tx pgx.Tx, quote string) ([]cashu.MeltChange, error) { 21 | 22 | var change []cashu.MeltChange 23 | for i := 0; i < len(m.MeltChange); i++ { 24 | 25 | if m.MeltChange[i].Quote == quote { 26 | change = append(change, m.MeltChange[i]) 27 | 28 | } 29 | 30 | } 31 | 32 | return change, nil 33 | } 34 | 35 | func (m *MockDB) DeleteChangeByQuote(tx pgx.Tx, quote string) error { 36 | for i := 0; i < len(m.MeltChange); i++ { 37 | 38 | if m.MeltChange[i].Quote == quote { 39 | m.MeltChange = append(m.MeltChange[:i], m.MeltChange[i+1:]...) 40 | } 41 | 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nutmix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release-from-version.yml: -------------------------------------------------------------------------------- 1 | name: Release on Tag Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Trigger on any tag push 7 | 8 | permissions: 9 | contents: write # Needed to create releases and upload artifacts 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: "1.23.12" 22 | 23 | - name: Install Just 24 | run: | 25 | sudo apt-get update 26 | sudo apt-get install -y just 27 | 28 | - name: Run release task 29 | run: just release 30 | 31 | - name: Create or Update GitHub Release 32 | uses: ncipollo/release-action@v1 33 | with: 34 | tag: ${{ github.ref_name }} 35 | name: Release ${{ github.ref_name }} 36 | draft: false 37 | prerelease: ${{ endsWith(github.ref_name, '-prelease') }} 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | artifacts: | 40 | release/* 41 | allowUpdates: true 42 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./build/nutmix" 8 | cmd = "just build-dev" 9 | delay = 1000 10 | exclude_dir = ["internal/routes/admin/static/node_modules", "tmp", "vendor", "testdata", "keycloak", "keycloak_pq_data", "internal/routes/admin/static/dist"] 11 | exclude_file = [] 12 | exclude_regex = [ "_templ.go", "pb.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "templ", "html", "css", "js"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [screen] 45 | clear_on_rebuild = false 46 | keep_scroll = true 47 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish-latest.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image latest 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build-and-push: 9 | name: Build and push Docker image 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v5 15 | 16 | - name: Set up QEMU 17 | uses: docker/setup-qemu-action@v3 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and push 29 | uses: docker/build-push-action@v6 30 | with: 31 | context: . 32 | file: ./Dockerfile 33 | push: true 34 | platforms: linux/amd64,linux/arm64 35 | tags: | 36 | ${{ secrets.DOCKERHUB_USERNAME }}/nutmix:latest 37 | 38 | - name: Image details 39 | run: | 40 | echo "Pushed image: ${{ secrets.DOCKERHUB_USERNAME }}/nutmix:latest" 41 | -------------------------------------------------------------------------------- /internal/database/goose/migrations/1_create_baseline.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE "seeds" ( 3 | seed bytea NOT NULL, 4 | active bool NOT NULL, 5 | unit text NOT NULL, 6 | id text NOT NULL, 7 | created_at int8 NOT NULL, 8 | CONSTRAINT seeds_pk PRIMARY KEY (id), 9 | CONSTRAINT seeds_unique UNIQUE (seed, id) 10 | ); 11 | 12 | CREATE TABLE mint_request ( 13 | quote text NOT NULL, 14 | request text NOT NULL, 15 | paid bool NOT NULL, 16 | expiry int4 NOT NULL, 17 | CONSTRAINT mint_request_pk PRIMARY KEY (quote) 18 | ); 19 | 20 | create table melt_request ( 21 | quote text NOT NULL, 22 | expiry int4 NOT NULL, 23 | fee_reserve int4 NOT NULL, 24 | request text NOT NULL, 25 | unit text NULL, 26 | amount int4 NOT NULL, 27 | paid bool NULL, 28 | CONSTRAINT melt_request_pk PRIMARY KEY (quote) 29 | ); 30 | 31 | create table proofs ( 32 | amount int4 NULL, 33 | id text NOT NULL, 34 | secret text NOT NULL, 35 | c text NOT NULL, 36 | CONSTRAINT proofs_seeds_fk FOREIGN KEY (id) REFERENCES seeds(id) 37 | ); 38 | 39 | 40 | -- +goose Down 41 | DROP TABLE IF EXISTS melt_request; 42 | DROP TABLE IF EXISTS mint_request; 43 | DROP TABLE IF EXISTS seeds; 44 | DROP TABLE IF EXISTS proofs; 45 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image on tag 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | 7 | jobs: 8 | build-and-push: 9 | name: Build and push Docker image 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v5 15 | 16 | - name: Set up QEMU 17 | uses: docker/setup-qemu-action@v3 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and push 29 | uses: docker/build-push-action@v6 30 | with: 31 | context: . 32 | file: ./Dockerfile 33 | push: true 34 | platforms: linux/amd64,linux/arm64 35 | tags: | 36 | ${{ secrets.DOCKERHUB_USERNAME }}/nutmix:${{ github.ref_name }} 37 | 38 | - name: Image details 39 | run: | 40 | echo "Pushed image: ${{ secrets.DOCKERHUB_USERNAME }}/nutmix:${{ github.ref_name }}" 41 | -------------------------------------------------------------------------------- /test/configTest/setup.go: -------------------------------------------------------------------------------- 1 | package configTest 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/lescuer97/nutmix/internal/mint" 8 | ) 9 | 10 | type ConfigFiles struct { 11 | TomlFile []byte 12 | } 13 | 14 | func CopyConfigFiles(filepath string) (ConfigFiles, error) { 15 | 16 | var config ConfigFiles 17 | 18 | file, err := os.ReadFile(filepath) 19 | 20 | if err != nil { 21 | 22 | return config, fmt.Errorf("could not read file: %w", err) 23 | 24 | } 25 | 26 | config.TomlFile = file 27 | 28 | return config, nil 29 | } 30 | 31 | func RemoveConfigFile(filepath string) error { 32 | 33 | err := os.Remove(filepath) 34 | if err != nil { 35 | return fmt.Errorf("os.Remove(), %w", err) 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func WriteConfigFile(file []byte) error { 42 | dir, err := os.UserConfigDir() 43 | 44 | if err != nil { 45 | return fmt.Errorf("os.UserHomeDir(), %w", err) 46 | } 47 | var pathToProjectDir = dir + "/" + mint.ConfigDirName 48 | var pathToProjectConfigFile = pathToProjectDir + "/" + mint.ConfigFileName 49 | 50 | err = os.WriteFile(pathToProjectConfigFile, file, 0764) 51 | if err != nil { 52 | return fmt.Errorf("os.WriteFile(pathToProjectConfigFile, file, 0764), %w", err) 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/routes/admin/static/card.css: -------------------------------------------------------------------------------- 1 | /* ============================================ 2 | BASE CARD FOUNDATION 3 | ============================================ */ 4 | .card { 5 | background: var(--bg-card); 6 | border: 1px solid var(--border-primary); 7 | border-radius: var(--radius-lg); 8 | transition: all var(--transition-base); 9 | } 10 | 11 | .card:hover { 12 | border-color: var(--border-secondary); 13 | background: var(--bg-card-hover); 14 | } 15 | 16 | /* Card with subtle glow on hover */ 17 | .card-glow:hover { 18 | box-shadow: var(--shadow-glow-cyan); 19 | } 20 | 21 | /* Card Sizes */ 22 | .card-sm { 23 | padding: var(--space-3) var(--space-4); 24 | } 25 | 26 | .card-md { 27 | padding: var(--space-5) var(--space-6); 28 | } 29 | 30 | .card-lg { 31 | padding: var(--space-6) var(--space-8); 32 | } 33 | 34 | /* Card with header section */ 35 | .card-header { 36 | padding: var(--space-4) var(--space-5); 37 | border-bottom: 1px solid var(--border-primary); 38 | display: flex; 39 | align-items: center; 40 | justify-content: space-between; 41 | } 42 | 43 | .card-header-title { 44 | font-size: var(--text-base); 45 | font-weight: var(--font-semibold); 46 | color: var(--text-primary); 47 | } 48 | 49 | .card-body { 50 | padding: var(--space-5); 51 | } 52 | 53 | .card-footer { 54 | padding: var(--space-4) var(--space-5); 55 | border-top: 1px solid var(--border-primary); 56 | } -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # hex endcoded 32 byte key 2 | MINT_PRIVATE_KEY="" # Private key of the mint 3 | ADMIN_NOSTR_NPUB="" # used for login to the admin dashboard 4 | 5 | # DATABASE 6 | POSTGRES_USER="postgres" 7 | POSTGRES_PASSWORD="" # Use a strong password 8 | 9 | DATABASE_URL="postgres://postgres:admin@db/postgres" # used in docker development and productions 10 | # DATABASE_URL="postgres://postgres:admin@localhost:5432/postgres" # used in local development 11 | 12 | # HOSTING 13 | LE_EMAIL_ADDRESS="email@forletsencrypt.com" 14 | MINT_HOSTNAME="mint.example.com" 15 | TRAEFIK_HOSTNAME="traefik.example.com" 16 | 17 | 18 | # set to prod for deployment 19 | MODE="prod" 20 | 21 | # Type of signer used by the mint 22 | SIGNER_TYPE="memory" 23 | # SIGNER_TYPE="abstract_socket" 24 | # SIGNER_TYPE="network" 25 | 26 | # PATHS FOR CERTIFICATED NEEDED FOR REMOTE SIGNER 27 | SIGNER_CLIENT_TLS_KEY="tls/client-cert.pem" 28 | SIGNER_CLIENT_TLS_CERT="tls/client-key.pem" 29 | SIGNER_CA_CERT="tls/ca-cert.pem" # Not obligatory all the time 30 | 31 | # for network signer 32 | # NETWORK_SIGNER_ADDRESS="localhost:1721" 33 | 34 | # Keycloak AND keycloak database 35 | KEYCLOAK_POSTGRES_DB=keycloak_db 36 | KEYCLOAK_POSTGRES_USER=keycloak_db_user 37 | KEYCLOAK_POSTGRES_PASSWORD=keycloak_db_user_password 38 | KEYCLOAK_ADMIN=admin 39 | KEYCLOAK_ADMIN_PASSWORD=password 40 | KEYCLOAK_HOSTNAME=localhost 41 | KEYCLOAK_HOSTNAME_PORT=8081 42 | 43 | 44 | PORT="" 45 | -------------------------------------------------------------------------------- /internal/routes/admin/token_blacklist.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // TokenBlacklist stores invalidated tokens in memory 9 | type TokenBlacklist struct { 10 | tokens map[string]time.Time // token -> expiration time 11 | mutex sync.RWMutex 12 | } 13 | 14 | // NewTokenBlacklist creates a new token blacklist 15 | func NewTokenBlacklist() *TokenBlacklist { 16 | return &TokenBlacklist{ 17 | tokens: make(map[string]time.Time), 18 | } 19 | } 20 | 21 | // AddToken adds a token to the blacklist with an expiration time 22 | func (tb *TokenBlacklist) AddToken(token string, expiration time.Time) { 23 | tb.mutex.Lock() 24 | defer tb.mutex.Unlock() 25 | tb.tokens[token] = expiration 26 | } 27 | 28 | // IsTokenBlacklisted checks if a token is in the blacklist and hasn't expired 29 | func (tb *TokenBlacklist) IsTokenBlacklisted(token string) bool { 30 | tb.mutex.RLock() 31 | defer tb.mutex.RUnlock() 32 | 33 | if exp, exists := tb.tokens[token]; exists { 34 | // Check if token has expired 35 | if time.Now().After(exp) { 36 | return false 37 | } 38 | return true 39 | } 40 | return false 41 | } 42 | 43 | // CleanupExpiredTokens removes expired tokens from the blacklist 44 | func (tb *TokenBlacklist) CleanupExpiredTokens() { 45 | tb.mutex.Lock() 46 | defer tb.mutex.Unlock() 47 | 48 | now := time.Now() 49 | for token, exp := range tb.tokens { 50 | if now.After(exp) { 51 | delete(tb.tokens, token) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM --platform=$BUILDPLATFORM golang:alpine3.22 AS builder 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | 7 | # Install build dependencies 8 | RUN apk add --no-cache protobuf curl unzip bash git 9 | 10 | # Install just 11 | RUN curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin 12 | 13 | # Set up working directory 14 | WORKDIR /app 15 | 16 | # Set PATH early so bun and go tools are available after installation 17 | ENV PATH="${PATH}:/root/go/bin:/root/.bun/bin" 18 | 19 | # Copy all source files 20 | COPY . . 21 | 22 | # Install all tools using just 23 | RUN just install-deps 24 | 25 | # install the web dependencies 26 | RUN just web-install 27 | 28 | # Generate protobuf code 29 | RUN just gen-proto 30 | 31 | # Generate templ files 32 | RUN just gen-templ 33 | 34 | # Build web assets 35 | RUN just web-build-prod 36 | 37 | # Build Go binary with correct target architecture 38 | RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" \ 39 | -trimpath -o build/nutmix cmd/nutmix/*.go 40 | 41 | # Runtime stage 42 | FROM alpine:3.22 43 | 44 | # Install runtime dependencies 45 | RUN apk add --no-cache ca-certificates tzdata 46 | 47 | WORKDIR /app 48 | 49 | # Copy the binary from builder 50 | COPY --from=builder /app/build/nutmix ./main 51 | 52 | # # Copy web assets 53 | # COPY --from=builder /app/internal/routes/admin/static/dist ./internal/routes/admin/static/dist 54 | 55 | EXPOSE 8080 56 | 57 | CMD ["/app/main"] 58 | -------------------------------------------------------------------------------- /internal/routes/admin/logout.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/golang-jwt/jwt/v5" 9 | ) 10 | 11 | // LogoutHandler handles user logout requests 12 | func LogoutHandler(blacklist *TokenBlacklist) gin.HandlerFunc { 13 | return func(c *gin.Context) { 14 | // Extract token from cookie 15 | tokenString, err := c.Cookie(AdminAuthKey) 16 | if err != nil { 17 | // No token found, redirect to login 18 | c.Header("HX-Redirect", "/admin/login") 19 | c.Status(http.StatusOK) 20 | return 21 | } 22 | 23 | // Add token to blacklist with expiration time 24 | // Parse token to get expiration time 25 | token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { 26 | // We don't need to validate here, just parse to get claims 27 | return []byte(""), nil 28 | }) 29 | 30 | expirationTime := time.Now().Add(24 * time.Hour) // Default expiration 31 | if token != nil && token.Claims != nil { 32 | if claims, ok := token.Claims.(jwt.MapClaims); ok { 33 | if exp, ok := claims["exp"].(float64); ok { 34 | expirationTime = time.Unix(int64(exp), 0) 35 | } 36 | } 37 | } 38 | 39 | // Add token to blacklist 40 | blacklist.AddToken(tokenString, expirationTime) 41 | 42 | // Clear the cookie 43 | c.SetCookie(AdminAuthKey, "", -1, "/", "", false, true) 44 | 45 | // Send HTMX redirect to login page 46 | c.Header("HX-Redirect", "/admin/login") 47 | c.Status(http.StatusOK) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/database/mock_db/auth.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jackc/pgx/v5" 8 | "github.com/lescuer97/nutmix/internal/database" 9 | ) 10 | 11 | func (m *MockDB) MakeAuthUser(tx pgx.Tx, auth database.AuthUser) error { 12 | 13 | _, err := tx.Exec(context.Background(), "INSERT INTO user_auth (sub, aud , last_logged_in) VALUES ($1, $2, $3)", auth.Sub, auth.Aud, auth.LastLoggedIn) 14 | 15 | if err != nil { 16 | return databaseError(fmt.Errorf("inserting to auth user login: %w", err)) 17 | 18 | } 19 | return nil 20 | 21 | } 22 | 23 | func (m *MockDB) GetAuthUser(tx pgx.Tx, sub string) (database.AuthUser, error) { 24 | rows, err := tx.Query(context.Background(), "SELECT sub, aud , last_logged_in FROM user_auth WHERE nonce = $1 FOR UPDATE", sub) 25 | if err != nil { 26 | return database.AuthUser{}, fmt.Errorf("error checking for active seeds: %w", err) 27 | } 28 | defer rows.Close() 29 | 30 | nostrLogin, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[database.AuthUser]) 31 | 32 | if err != nil { 33 | return nostrLogin, fmt.Errorf("pgx.CollectOneRow(rows, pgx.RowToStructByName[cashu.NostrLoginAuth]): %w", err) 34 | } 35 | 36 | return nostrLogin, nil 37 | 38 | } 39 | 40 | func (m *MockDB) UpdateLastLoggedIn(tx pgx.Tx, sub string, lastLoggedIn uint64) error { 41 | // change the paid status of the quote 42 | _, err := tx.Exec(context.Background(), "UPDATE user_auth SET last_logged_in = $1 WHERE sub = $2", lastLoggedIn, sub) 43 | if err != nil { 44 | return databaseError(fmt.Errorf("update to seeds: %w", err)) 45 | 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/database/postgresql/auth.go: -------------------------------------------------------------------------------- 1 | package postgresql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/jackc/pgx/v5" 8 | "github.com/lescuer97/nutmix/internal/database" 9 | ) 10 | 11 | func (pql Postgresql) MakeAuthUser(tx pgx.Tx, auth database.AuthUser) error { 12 | 13 | _, err := tx.Exec(context.Background(), "INSERT INTO user_auth (sub, aud , last_logged_in) VALUES ($1, $2, $3)", auth.Sub, auth.Aud, auth.LastLoggedIn) 14 | 15 | if err != nil { 16 | return databaseError(fmt.Errorf("inserting to auth user login: %w", err)) 17 | 18 | } 19 | return nil 20 | 21 | } 22 | 23 | func (pql Postgresql) GetAuthUser(tx pgx.Tx, sub string) (database.AuthUser, error) { 24 | rows, err := tx.Query(context.Background(), "SELECT sub, aud , last_logged_in FROM user_auth WHERE sub = $1 FOR UPDATE", sub) 25 | if err != nil { 26 | return database.AuthUser{}, fmt.Errorf("error checking for active seeds: %w", err) 27 | } 28 | defer rows.Close() 29 | 30 | nostrLogin, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[database.AuthUser]) 31 | 32 | if err != nil { 33 | return nostrLogin, fmt.Errorf("pgx.CollectOneRow(rows, pgx.RowToStructByName[cashu.NostrLoginAuth]): %w", err) 34 | } 35 | 36 | return nostrLogin, nil 37 | } 38 | 39 | func (pql Postgresql) UpdateLastLoggedIn(tx pgx.Tx, sub string, lastLoggedIn uint64) error { 40 | // change the paid status of the quote 41 | _, err := tx.Exec(context.Background(), "UPDATE user_auth SET last_logged_in = $1 WHERE sub = $2", lastLoggedIn, sub) 42 | if err != nil { 43 | return databaseError(fmt.Errorf("update to seeds: %w", err)) 44 | 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /api/cashu/websocket.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | type WebRequestMethod string 4 | 5 | const Unsubcribe WebRequestMethod = "unsubscribe" 6 | const Subcribe WebRequestMethod = "subscribe" 7 | 8 | type SubscriptionKind string 9 | 10 | const Bolt11MeltQuote SubscriptionKind = "bolt11_melt_quote" 11 | const Bolt11MintQuote SubscriptionKind = "bolt11_mint_quote" 12 | const ProofStateWs SubscriptionKind = "proof_state" 13 | 14 | type WebRequestParams struct { 15 | Kind SubscriptionKind `json:"kind,omitempty"` 16 | SubId string `json:"subId"` 17 | Filters []string `json:"filters,omitempty"` 18 | Payload any `json:"payload,omitempty"` 19 | } 20 | 21 | type WsRequest struct { 22 | JsonRpc string `json:"jsonrpc"` 23 | Method WebRequestMethod `json:"method"` 24 | Params WebRequestParams `json:"params"` 25 | Id int `json:"id"` 26 | } 27 | 28 | type WsResponseResult struct { 29 | Status string `json:"status"` 30 | SubId string `json:"subId"` 31 | } 32 | 33 | type WsResponse struct { 34 | JsonRpc string `json:"jsonrpc"` 35 | Result WsResponseResult `json:"result"` 36 | Id int `json:"id"` 37 | } 38 | 39 | type WsNotification struct { 40 | JsonRpc string `json:"jsonrpc"` 41 | Method WebRequestMethod `json:"method"` 42 | Params WebRequestParams `json:"params"` 43 | Id int `json:"id,omitempty"` 44 | } 45 | 46 | type ErrorMsg struct { 47 | Code uint64 `json:"code"` 48 | Message string `json:"message"` 49 | } 50 | type WsError struct { 51 | JsonRpc string `json:"jsonrpc"` 52 | Error ErrorMsg `json:"error"` 53 | Id int `json:"id"` 54 | } 55 | -------------------------------------------------------------------------------- /internal/routes/middleware/cache.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/gin-contrib/cache/persistence" 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | type responseWriter struct { 17 | gin.ResponseWriter 18 | body *bytes.Buffer 19 | } 20 | 21 | func (w responseWriter) Write(b []byte) (int, error) { 22 | w.body.Write(b) 23 | return w.ResponseWriter.Write(b) 24 | } 25 | 26 | var cachedPaths = map[string]bool{ 27 | "/v1/mint/bolt11": true, 28 | "/v1/melt/bolt11": true, 29 | "/v1/swap": true, 30 | } 31 | 32 | func CacheMiddleware(store *persistence.InMemoryStore) gin.HandlerFunc { 33 | return func(c *gin.Context) { 34 | 35 | if !cachedPaths[c.Request.URL.Path] { 36 | c.Next() 37 | return 38 | } 39 | 40 | body, err := io.ReadAll(c.Request.Body) 41 | if err != nil { 42 | c.Next() 43 | return 44 | } 45 | c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) 46 | 47 | hash := sha256.Sum256(body) 48 | cacheKey := c.Request.URL.Path + "-" + fmt.Sprintf("%x", hash) 49 | 50 | var cachedResponse []byte 51 | if err := store.Get(cacheKey, &cachedResponse); err == nil { 52 | c.Data(http.StatusOK, "application/json; charset=utf-8", cachedResponse) 53 | c.Abort() 54 | return 55 | } 56 | 57 | w := &responseWriter{body: &bytes.Buffer{}, ResponseWriter: c.Writer} 58 | c.Writer = w 59 | c.Next() 60 | if c.Writer.Status() == http.StatusOK { 61 | if err := store.Set(cacheKey, w.body.Bytes(), 45*time.Minute); err != nil { 62 | slog.Warn("failed to set cache", slog.Any("error", err)) 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/login.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | templ LayoutEmpty() { 4 | 5 | 6 | @head() 7 | 8 |
9 | { children... } 10 | 11 | 12 | } 13 | 14 | templ LockedLockSvgIcon() { 15 |
16 | 17 |
18 | } 19 | 20 | templ LoginPage(nonce string, adminNpubActive bool) { 21 | @LayoutEmpty() { 22 |
23 | if !adminNpubActive { 24 |
25 |

26 | Please set the ADMIN_NOSTR_NPUB enviroment variable to be able to access the 27 | Admin dashboard. 28 |

29 |

30 | This will allow you to change the configuration of the Mint. 31 |

32 |
33 | } else { 34 |
35 |
36 | @LockedLockSvgIcon() 37 |

Please make sure that you have a NIP-07 Browser Extension installed.

38 | 39 | 40 |
41 |
42 | } 43 |
44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/routes/admin/static/summary.css: -------------------------------------------------------------------------------- 1 | /* Summary Card Styles */ 2 | .summary-card { 3 | background: var(--bg-card); 4 | border: 1px solid var(--border-primary); 5 | border-radius: var(--radius-lg); 6 | padding: var(--space-4); 7 | display: flex; 8 | align-items: center; 9 | gap: var(--space-4); 10 | box-shadow: var(--shadow-sm); 11 | transition: all var(--transition-base); 12 | } 13 | 14 | .summary-card:hover { 15 | border-color: var(--border-secondary); 16 | box-shadow: var(--shadow-md); 17 | transform: translateY(-2px); 18 | } 19 | 20 | .summary-icon { 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | width: 48px; 25 | height: 48px; 26 | border-radius: var(--radius-full); 27 | background: var(--bg-secondary); 28 | color: var(--accent-cyan); 29 | } 30 | 31 | .summary-content { 32 | display: flex; 33 | flex-direction: column; 34 | gap: var(--space-1); 35 | } 36 | 37 | .summary-label { 38 | font-size: var(--text-sm); 39 | font-weight: var(--font-medium); 40 | color: var(--text-secondary); 41 | text-transform: uppercase; 42 | letter-spacing: 0.05em; 43 | } 44 | 45 | .summary-value { 46 | font-size: var(--text-2xl); 47 | font-weight: var(--font-bold); 48 | color: var(--text-primary); 49 | line-height: 1.2; 50 | } 51 | 52 | .summary-subtext { 53 | font-size: var(--text-xs); 54 | color: var(--text-tertiary); 55 | } 56 | 57 | /* Chart specific overrides */ 58 | .chart-summary-container { 59 | display: grid; 60 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 61 | gap: var(--space-4); 62 | margin-bottom: var(--space-6); 63 | } 64 | 65 | -------------------------------------------------------------------------------- /internal/mint/proofs.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "slices" 8 | 9 | "github.com/lescuer97/nutmix/api/cashu" 10 | ) 11 | 12 | func CheckProofState(mint *Mint, Ys []cashu.WrappedPublicKey) ([]cashu.CheckState, error) { 13 | var states []cashu.CheckState 14 | ctx := context.Background() 15 | tx, err := mint.MintDB.GetTx(ctx) 16 | if err != nil { 17 | return states, fmt.Errorf("m.MintDB.GetTx(ctx). %w", err) 18 | } 19 | defer func() { 20 | if err != nil { 21 | if rollbackErr := mint.MintDB.Rollback(ctx, tx); rollbackErr != nil { 22 | slog.Warn("rollback error", slog.Any("error", rollbackErr)) 23 | } 24 | } 25 | }() 26 | 27 | // set as unspent 28 | proofs, err := mint.MintDB.GetProofsFromSecretCurve(tx, Ys) 29 | if err != nil { 30 | return states, fmt.Errorf("database.CheckListOfProofsBySecretCurve(pool, Ys). %w", err) 31 | } 32 | 33 | err = mint.MintDB.Commit(ctx, tx) 34 | if err != nil { 35 | return states, fmt.Errorf("mint.MintDB.Commit(ctx tx). %w", err) 36 | } 37 | 38 | proofsForRemoval := make([]cashu.Proof, 0) 39 | 40 | for _, state := range Ys { 41 | 42 | pendingAndSpent := false 43 | 44 | checkState := cashu.CheckState{ 45 | Y: state, 46 | State: cashu.PROOF_UNSPENT, 47 | Witness: nil, 48 | } 49 | 50 | switch { 51 | // Check if is in list of spents and if its also pending add it for removal of pending list 52 | case slices.ContainsFunc(proofs, func(p cashu.Proof) bool { 53 | compare := p.Y.ToHex() == state.ToHex() 54 | if p.Witness != "" { 55 | checkState.Witness = &p.Witness 56 | } 57 | if compare && pendingAndSpent { 58 | 59 | proofsForRemoval = append(proofsForRemoval, p) 60 | } 61 | return compare 62 | }): 63 | checkState.State = cashu.PROOF_SPENT 64 | } 65 | 66 | states = append(states, checkState) 67 | } 68 | 69 | return states, nil 70 | } 71 | -------------------------------------------------------------------------------- /api/cashu/util_test.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "github.com/tyler-smith/go-bip32" 5 | "testing" 6 | ) 7 | 8 | func TestOrderKeysetByUnit(t *testing.T) { 9 | // setup key 10 | key, err := bip32.NewMasterKey([]byte("seed")) 11 | if err != nil { 12 | t.Errorf("could not setup master key %+v", err) 13 | } 14 | 15 | seed := Seed{ 16 | Id: "id", 17 | Unit: Sat.String(), 18 | Version: 0, 19 | InputFeePpk: 0, 20 | } 21 | 22 | generatedKeysets, err := GenerateKeysets(key, GetAmountsForKeysets(), seed) 23 | if err != nil { 24 | t.Errorf("could not generate keyset %+v", err) 25 | } 26 | 27 | orderedKeys := OrderKeysetByUnit(generatedKeysets) 28 | 29 | firstOrdKey := orderedKeys["keysets"][0] 30 | 31 | if firstOrdKey.Keys["1"] != "03fbf65684a42313691fe562aa315f26409a19aaaaa8ef0163fc8d8598f16fe003" { 32 | t.Errorf("keyset is not correct") 33 | } 34 | 35 | } 36 | 37 | func TestAmountOfFeeProofs(t *testing.T) { 38 | 39 | var proofs []Proof 40 | var keysets []BasicKeysetResponse 41 | id := "keysetID" 42 | inputFee := uint(100) 43 | 44 | for i := 0; i < 9; i++ { 45 | // add 9 proofs 46 | proof := Proof{ 47 | Id: id, 48 | } 49 | 50 | keyset := BasicKeysetResponse{ 51 | Id: id, 52 | InputFeePpk: inputFee, 53 | } 54 | 55 | proofs = append(proofs, proof) 56 | keysets = append(keysets, keyset) 57 | } 58 | 59 | fee, _ := Fees(proofs, keysets) 60 | 61 | if fee != 1 { 62 | t.Errorf("fee calculation is incorrect: %v. Should be 1", fee) 63 | } 64 | 65 | for i := 0; i < 3; i++ { 66 | // add 9 proofs 67 | proof := Proof{ 68 | Id: id, 69 | } 70 | 71 | keyset := BasicKeysetResponse{ 72 | Id: id, 73 | InputFeePpk: inputFee, 74 | } 75 | 76 | proofs = append(proofs, proof) 77 | keysets = append(keysets, keyset) 78 | } 79 | fee, _ = Fees(proofs, keysets) 80 | 81 | if fee != 2 { 82 | t.Errorf("fee calculation is incorrect: %v. Should be 2", fee) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /test/configTest/main_test.go: -------------------------------------------------------------------------------- 1 | package configTest 2 | 3 | // import ( 4 | // "github.com/lescuer97/nutmix/internal/mint" 5 | // "testing" 6 | // ) 7 | 8 | // func TestSetupConfigWithAlreadyExistingEnv(t *testing.T) { 9 | // 10 | // // Setup Existing Env Variables 11 | // 12 | // t.Setenv("NAME", "test-name") 13 | // t.Setenv("DESCRIPTION", "mint description") 14 | // t.Setenv("MOTD", "important") 15 | // 16 | // t.Setenv("NETWORK", "signet") 17 | // t.Setenv("MINT_LIGHTNING_BACKEND", "LndGrpcWallet") 18 | // 19 | // // Setup Config 20 | // config, err := mint.SetUpConfigFile() 21 | // 22 | // if err != nil { 23 | // t.Errorf("Could not setup Config File") 24 | // } 25 | // 26 | // if config.NAME != "test-name" { 27 | // t.Errorf("Could not check") 28 | // } 29 | // 30 | // if config.DESCRIPTION != "mint description" { 31 | // t.Errorf("Could not check") 32 | // } 33 | // 34 | // if config.MOTD != "important" { 35 | // t.Errorf("Could not check") 36 | // } 37 | // 38 | // if config.NETWORK != "signet" { 39 | // t.Errorf("Could not check") 40 | // } 41 | // 42 | // if config.MINT_LIGHTNING_BACKEND != "LndGrpcWallet" { 43 | // t.Errorf("Could not check") 44 | // } 45 | // 46 | // 47 | // if err != nil { 48 | // t.Errorf("Could not rewrite config file to original %+v", err) 49 | // } 50 | // 51 | // } 52 | // 53 | // func TestSetupConfigWithoutEnvVars(t *testing.T) { 54 | // 55 | // // Setup Config 56 | // config, err := mint.SetUpConfigFile() 57 | // if err != nil { 58 | // t.Errorf("Could not setup Config File") 59 | // } 60 | // 61 | // if config.NETWORK != "mainnet" { 62 | // t.Errorf("Network is not default") 63 | // } 64 | // if config.NAME != "" { 65 | // t.Errorf("name is not default") 66 | // } 67 | // if config.MINT_LIGHTNING_BACKEND != "FakeWallet" { 68 | // t.Errorf("Mint lightning backend is not default") 69 | // } 70 | // 71 | // // err = WriteConfigFile(originalCopyFile.TomlFile) 72 | // 73 | // if err != nil { 74 | // t.Errorf("Could not rewrite config file to original %+v", err) 75 | // } 76 | // 77 | // } 78 | -------------------------------------------------------------------------------- /internal/lightning/backend.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/btcsuite/btcd/chaincfg" 7 | "github.com/lescuer97/nutmix/api/cashu" 8 | "github.com/lightningnetwork/lnd/zpay32" 9 | ) 10 | 11 | var ( 12 | ErrAlreadyPaid = errors.New("invoice already paid") 13 | ) 14 | 15 | type Backend uint 16 | 17 | const LNDGRPC Backend = iota + 1 18 | const LNBITS Backend = iota + 2 19 | const CLNGRPC Backend = iota + 3 20 | const FAKEWALLET Backend = iota + 4 21 | const STRIKE Backend = iota + 5 22 | 23 | type LightningBackend interface { 24 | PayInvoice(melt_quote cashu.MeltRequestDB, zpayInvoice *zpay32.Invoice, feeReserve uint64, mpp bool, amount cashu.Amount) (PaymentResponse, error) 25 | CheckPayed(quote string, invoice *zpay32.Invoice, checkingId string) (PaymentStatus, string, uint64, error) 26 | CheckReceived(quote cashu.MintRequestDB, invoice *zpay32.Invoice) (PaymentStatus, string, error) 27 | RequestInvoice(quote cashu.MintRequestDB, amount cashu.Amount) (InvoiceResponse, error) 28 | // returns the amount in sats and the checking_id 29 | QueryFees(invoice string, zpayInvoice *zpay32.Invoice, mpp bool, amount cashu.Amount) (FeesResponse, error) 30 | // returns milisats balance 31 | WalletBalance() (uint64, error) 32 | LightningType() Backend 33 | GetNetwork() *chaincfg.Params 34 | ActiveMPP() bool 35 | VerifyUnitSupport(unit cashu.Unit) bool 36 | DescriptionSupport() bool 37 | } 38 | 39 | type PaymentStatus uint 40 | 41 | const SETTLED PaymentStatus = iota + 1 42 | const FAILED PaymentStatus = iota + 2 43 | const PENDING PaymentStatus = iota + 3 44 | const UNKNOWN PaymentStatus = iota + 999 45 | 46 | type PaymentResponse struct { 47 | Preimage string 48 | PaymentRequest string 49 | PaymentState PaymentStatus 50 | Rhash string 51 | PaidFeeSat int64 52 | CheckingId string 53 | } 54 | type FeesResponse struct { 55 | Fees cashu.Amount 56 | AmountToSend cashu.Amount 57 | CheckingId string 58 | } 59 | 60 | type InvoiceResponse struct { 61 | PaymentRequest string 62 | CheckingId string 63 | Rhash string 64 | } 65 | -------------------------------------------------------------------------------- /internal/utils/liquidityManager.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | type SwapState string 8 | 9 | const WaitingUserConfirmation SwapState = "WaitingUserConfirmation" 10 | const MintWaitingPaymentRecv SwapState = "MintWaitingPaymentRecv" 11 | 12 | const Finished SwapState = "Finished" 13 | const Expired SwapState = "Expired" 14 | const LightningPaymentFail SwapState = "LightningPaymentFail" 15 | const LightningPaymentPending SwapState = "LightningPaymentPending" 16 | const LightningPaymentExpired SwapState = "LightningPaymentExpired" 17 | const UnknownProblem SwapState = "UnknownProblem" 18 | 19 | var ErrAlreadyLNPaying = errors.New("already paying lightning invoice") 20 | 21 | func (s SwapState) ToString() string { 22 | switch s { 23 | case WaitingUserConfirmation: 24 | return "Waiting for confirmation" 25 | case MintWaitingPaymentRecv: 26 | return "Waiting Receive Payment" 27 | case Finished: 28 | return string(Finished) 29 | case Expired: 30 | return string(Expired) 31 | case LightningPaymentFail: 32 | return "Failed lightning payment" 33 | case LightningPaymentExpired: 34 | return "Lighting payment expired" 35 | case LightningPaymentPending: 36 | return "Payment pending" 37 | case UnknownProblem: 38 | return "Unknown problem happened" 39 | } 40 | return "" 41 | } 42 | 43 | type SwapType string 44 | 45 | const LiquidityOut SwapType = "LiquidityOut" 46 | const LiquidityIn SwapType = "LiquidityIn" 47 | 48 | func (s SwapType) ToString() string { 49 | 50 | switch s { 51 | case LiquidityOut: 52 | return "Out" 53 | case LiquidityIn: 54 | return "In" 55 | 56 | } 57 | return "" 58 | } 59 | 60 | func CanUseLiquidityManager(backend LightningBackend) bool { 61 | switch backend { 62 | case FAKE_WALLET: 63 | return false 64 | default: 65 | return true 66 | } 67 | } 68 | 69 | type LiquiditySwap struct { 70 | Amount uint64 `json:"amount"` 71 | Id string `json:"id"` 72 | State SwapState `json:"state"` 73 | Type SwapType `json:"type"` 74 | Expiration uint64 `json:"expiration"` 75 | LightningInvoice string `db:"lightning_invoice"` 76 | CheckingId string `db:"checking_id"` 77 | } 78 | -------------------------------------------------------------------------------- /internal/database/postgresql/change.go: -------------------------------------------------------------------------------- 1 | package postgresql 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/jackc/pgx/v5" 10 | "github.com/lescuer97/nutmix/api/cashu" 11 | ) 12 | 13 | func (pql Postgresql) SaveMeltChange(tx pgx.Tx, change []cashu.BlindedMessage, quote string) error { 14 | entries := [][]any{} 15 | columns := []string{`B_`, "created_at", "id", "quote"} 16 | tableName := "melt_change_message" 17 | 18 | tries := 0 19 | 20 | now := time.Now().Unix() 21 | for _, sig := range change { 22 | entries = append(entries, []any{sig.B_.String(), now, sig.Id, quote}) 23 | } 24 | 25 | for { 26 | tries += 1 27 | _, err := tx.CopyFrom(context.Background(), pgx.Identifier{tableName}, columns, pgx.CopyFromRows(entries)) 28 | 29 | switch { 30 | case err != nil && tries < 3: 31 | continue 32 | case err != nil && tries >= 3: 33 | return databaseError(fmt.Errorf("inserting to DB: %w", err)) 34 | case err == nil: 35 | return nil 36 | } 37 | 38 | } 39 | } 40 | 41 | func (pql Postgresql) GetMeltChangeByQuote(tx pgx.Tx, quote string) ([]cashu.MeltChange, error) { 42 | 43 | meltChangeList := make([]cashu.MeltChange, 0) 44 | 45 | rows, err := tx.Query(context.Background(), `SELECT "B_", id, quote, created_at FROM melt_change_message WHERE quote = $1 FOR UPDATE NOWAIT`, quote) 46 | 47 | if err != nil { 48 | if errors.Is(err, pgx.ErrNoRows) { 49 | return meltChangeList, nil 50 | } 51 | } 52 | defer rows.Close() 53 | 54 | meltChange, err := pgx.CollectRows(rows, pgx.RowToStructByName[cashu.MeltChange]) 55 | 56 | if err != nil { 57 | if errors.Is(err, pgx.ErrNoRows) { 58 | return meltChangeList, nil 59 | } 60 | return meltChangeList, fmt.Errorf("pgx.CollectRows(rows, pgx.RowToStructByName[cashu.Proof]): %w", err) 61 | } 62 | 63 | meltChangeList = meltChange 64 | 65 | return meltChangeList, nil 66 | } 67 | func (pql Postgresql) DeleteChangeByQuote(tx pgx.Tx, quote string) error { 68 | 69 | _, err := tx.Exec(context.Background(), `DELETE FROM melt_change_message WHERE quote = $1`, quote) 70 | 71 | if err != nil { 72 | return databaseError(fmt.Errorf("pql.pool.Exec(context.Background(), `DELETE FROM melt_change_message WHERE quote = $1`, quote): %w", err)) 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/lightning/lightning_test.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/btcsuite/btcd/chaincfg" 7 | "github.com/lescuer97/nutmix/api/cashu" 8 | "github.com/lightningnetwork/lnd/zpay32" 9 | ) 10 | 11 | func TestUseMinimumFeeOnInvoice(t *testing.T) { 12 | chainParam := chaincfg.MainNetParams 13 | fakeWallet := FakeWallet{ 14 | Network: chainParam, 15 | InvoiceFee: 2, 16 | } 17 | 18 | expireTime := cashu.ExpiryTimeMinUnit(15) 19 | invoiceString, err := CreateMockInvoice(10000, "test", chainParam, expireTime) 20 | if err != nil { 21 | t.Fatalf(`CreateMockInvoice(10000, "test", chaincfg.MainNetParams,expireTime). %v`, err) 22 | } 23 | 24 | invoice, err := zpay32.Decode(invoiceString, &chainParam) 25 | 26 | if err != nil { 27 | t.Fatalf(`zpay32.Decode(invoiceString, &chainParam). %v`, err) 28 | } 29 | 30 | sat_amount := uint64(invoice.MilliSat.ToSatoshis()) 31 | 32 | feeRes, err := fakeWallet.QueryFees(invoiceString, invoice, false, cashu.Amount{Amount: sat_amount, Unit: cashu.Sat}) 33 | if err != nil { 34 | t.Fatalf(`fakeWallet.QueryFees(). %v`, err) 35 | } 36 | 37 | if feeRes.Fees.Amount != 100 { 38 | 39 | t.Errorf(`Fee is not being set to the correct value. %v`, feeRes.Fees.Amount) 40 | } 41 | } 42 | 43 | func TestUseFeeInvoice(t *testing.T) { 44 | chainParam := chaincfg.MainNetParams 45 | fakeWallet := FakeWallet{ 46 | Network: chainParam, 47 | InvoiceFee: 150, 48 | } 49 | 50 | expireTime := cashu.ExpiryTimeMinUnit(15) 51 | invoiceString, err := CreateMockInvoice(10000, "test", chainParam, expireTime) 52 | if err != nil { 53 | t.Fatalf(`CreateMockInvoice(10000, "test", chaincfg.MainNetParams,expireTime). %v`, err) 54 | } 55 | 56 | invoice, err := zpay32.Decode(invoiceString, &chainParam) 57 | 58 | if err != nil { 59 | t.Fatalf(`zpay32.Decode(invoiceString, &chainParam). %v`, err) 60 | } 61 | 62 | sat_amount := uint64(invoice.MilliSat.ToSatoshis()) 63 | 64 | feeRes, err := fakeWallet.QueryFees(invoiceString, invoice, false, cashu.Amount{Amount: sat_amount, Unit: cashu.Sat}) 65 | if err != nil { 66 | t.Fatalf(`fakeWallet.QueryFees(). %v`, err) 67 | } 68 | 69 | if feeRes.Fees.Amount != 150 { 70 | 71 | t.Errorf(`Fee is not being set to the correct value. %v`, feeRes.Fees) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/setupTest/lnd_test.go: -------------------------------------------------------------------------------- 1 | package setuptest 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/btcsuite/btcd/chaincfg" 9 | "github.com/lescuer97/nutmix/api/cashu" 10 | "github.com/lescuer97/nutmix/internal/lightning" 11 | "github.com/lescuer97/nutmix/internal/utils" 12 | ) 13 | 14 | func TestSetupLightingCommsLND(t *testing.T) { 15 | // setup 16 | ctx := context.Background() 17 | _, _, _, _, err := utils.SetUpLightingNetworkTestEnviroment(ctx, "lightingsetup-test") 18 | t.Setenv("MINT_LIGHTNING_BACKEND", "LndGrpcWallet") 19 | 20 | lnd_host := os.Getenv(utils.LND_HOST) 21 | tls_cert := os.Getenv(utils.LND_TLS_CERT) 22 | macaroon := os.Getenv(utils.LND_MACAROON) 23 | 24 | if err != nil { 25 | t.Fatalf("setUpLightingNetworkEnviroment %+v", err) 26 | } 27 | lndWallet := lightning.LndGrpcWallet{ 28 | Network: chaincfg.RegressionNetParams, 29 | } 30 | 31 | err = lndWallet.SetupGrpc(lnd_host, macaroon, tls_cert) 32 | if err != nil { 33 | t.Fatalf("setUpLightingNetworkEnviroment %+v", err) 34 | } 35 | 36 | invoice, err := lndWallet.RequestInvoice(cashu.MintRequestDB{}, cashu.Amount{Amount: 1000, Unit: cashu.Sat}) 37 | if err != nil { 38 | t.Fatalf("could not setup lighting comms %+v", err) 39 | } 40 | 41 | if len(invoice.PaymentRequest) == 0 { 42 | t.Fatalf("There is no payment request %+v", err) 43 | } 44 | 45 | } 46 | 47 | func TestSetupLightingCommsLnBits(t *testing.T) { 48 | // setup 49 | ctx := context.Background() 50 | _, _, _, _, err := utils.SetUpLightingNetworkTestEnviroment(ctx, "lnbits-test") 51 | t.Setenv("MINT_LIGHTNING_BACKEND", "LNbitsWallet") 52 | 53 | endpoint := os.Getenv(utils.MINT_LNBITS_ENDPOINT) 54 | key := os.Getenv(utils.MINT_LNBITS_KEY) 55 | 56 | lnbitsWallet := lightning.LnbitsWallet{ 57 | Network: chaincfg.RegressionNetParams, 58 | Key: key, 59 | Endpoint: endpoint, 60 | } 61 | 62 | if err != nil { 63 | t.Fatalf("setUpLightingNetworkEnviroment %+v", err) 64 | } 65 | invoice, err := lnbitsWallet.RequestInvoice(cashu.MintRequestDB{}, cashu.Amount{Amount: 1000, Unit: cashu.Sat}) 66 | if err != nil { 67 | t.Fatalf("could not setup lighting comms %+v", err) 68 | } 69 | 70 | if len(invoice.PaymentRequest) == 0 { 71 | t.Fatalf("There is no payment request %+v", err) 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /api/cashu/util.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "strconv" 7 | ) 8 | 9 | func OrderKeysetByUnit(keysets []MintKey) KeysResponse { 10 | var typesOfUnits = make(map[string][]MintKey) 11 | 12 | for _, keyset := range keysets { 13 | if len(typesOfUnits[keyset.Unit]) == 0 { 14 | typesOfUnits[keyset.Unit] = append(typesOfUnits[keyset.Unit], keyset) 15 | continue 16 | } else { 17 | typesOfUnits[keyset.Unit] = append(typesOfUnits[keyset.Unit], keyset) 18 | } 19 | } 20 | 21 | res := make(map[string][]Keyset) 22 | 23 | res["keysets"] = []Keyset{} 24 | 25 | for _, value := range typesOfUnits { 26 | var keysetResponse Keyset 27 | keysetResponse.Id = value[0].Id 28 | keysetResponse.Unit = value[0].Unit 29 | keysetResponse.Keys = make(map[string]string) 30 | keysetResponse.InputFeePpk = value[0].InputFeePpk 31 | keysetResponse.FinalExpiry = value[0].FinalExpiry 32 | 33 | for _, keyset := range value { 34 | 35 | keysetResponse.Keys[strconv.FormatUint(keyset.Amount, 10)] = hex.EncodeToString(keyset.PrivKey.PubKey().SerializeCompressed()) 36 | } 37 | 38 | res["keysets"] = append(res["keysets"], keysetResponse) 39 | } 40 | return res 41 | 42 | } 43 | 44 | func GenerateNonceHex() (string, error) { 45 | 46 | // generate random Nonce 47 | nonce := make([]byte, 32) // create a slice with length 16 for the nonce 48 | _, err := rand.Read(nonce) // read random bytes into the nonce slice 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | return hex.EncodeToString(nonce), nil 54 | } 55 | 56 | func Fees(proofs []Proof, keysets []BasicKeysetResponse) (uint, error) { 57 | totalFees := uint(0) 58 | 59 | var keysetToUse BasicKeysetResponse 60 | for _, proof := range proofs { 61 | // find keyset to compare to fees if keyset id is not found throw error 62 | // only check for new keyset if proofs id is different 63 | if keysetToUse.Id != proof.Id { 64 | for _, keyset := range keysets { 65 | if keyset.Id == proof.Id { 66 | 67 | keysetToUse = keyset 68 | } 69 | } 70 | if keysetToUse.Id != proof.Id { 71 | return 0, ErrKeysetForProofNotFound 72 | 73 | } 74 | 75 | } 76 | 77 | totalFees += keysetToUse.InputFeePpk 78 | 79 | } 80 | 81 | totalFees = (totalFees + 999) / 1000 82 | 83 | return totalFees, nil 84 | 85 | } 86 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16.8 4 | restart: always 5 | ports: 6 | - 127.0.0.1:5435:5432 7 | volumes: 8 | - db:/var/lib/postgresql/data 9 | environment: 10 | POSTGRES_USER: ${POSTGRES_USER} 11 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 12 | healthcheck: 13 | test: ["CMD-SHELL", "pg_isready -U postgres"] 14 | interval: 30s 15 | timeout: 30s 16 | retries: 3 17 | 18 | mint: 19 | env_file: 20 | - path: ".env" 21 | required: true 22 | environment: 23 | MODE: "prod" 24 | build: ./ 25 | ports: 26 | - 8080:8080 27 | restart: on-failure:10 28 | depends_on: 29 | - db 30 | volumes: 31 | - /var/log/nutmix:/var/log/nutmix 32 | - ${HOME}/.config/nutmix:/root/.config/nutmix 33 | 34 | # keycloak: 35 | # image: quay.io/keycloak/keycloak:25.0.6 36 | # command: start 37 | # environment: 38 | # KC_HOSTNAME: ${KEYCLOAK_HOSTNAME} 39 | # KC_HOSTNAME_PORT: ${KEYCLOAK_HOSTNAME_PORT} 40 | # KC_HOSTNAME_STRICT_BACKCHANNEL: false 41 | # KC_HTTP_ENABLED: true 42 | # KC_HOSTNAME_STRICT_HTTPS: true 43 | # KC_HEALTH_ENABLED: true 44 | # KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} 45 | # KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} 46 | # KC_DB: postgres 47 | # KC_DB_URL: jdbc:postgresql://keycloak_postgres/${KEYCLOAK_POSTGRES_DB} 48 | # KC_DB_USERNAME: ${KEYCLOAK_POSTGRES_USER} 49 | # KC_DB_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD} 50 | # ports: 51 | # - 8081:8080 52 | # restart: always 53 | # depends_on: 54 | # - keycloak_postgres 55 | # 56 | # keycloak_postgres: 57 | # image: postgres:16.8 58 | # volumes: 59 | # - ./keycloak_pq_data:/var/lib/postgresql/data 60 | # environment: 61 | # POSTGRES_DB: ${KEYCLOAK_POSTGRES_DB} 62 | # POSTGRES_USER: ${KEYCLOAK_POSTGRES_USER} 63 | # POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD} 64 | # healthcheck: 65 | # test: 66 | # [ 67 | # "CMD-SHELL", 68 | # "pg_isready -U ${KEYCLOAK_POSTGRES_USER} -d ${KEYCLOAK_POSTGRES_DB}", 69 | # ] 70 | # interval: 20s 71 | # timeout: 5s 72 | # retries: 10 73 | volumes: 74 | db: 75 | driver: local 76 | keycloak_pq_data: 77 | driver: local 78 | -------------------------------------------------------------------------------- /internal/mint/config.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/BurntSushi/toml" 10 | "github.com/lescuer97/nutmix/internal/database" 11 | "github.com/lescuer97/nutmix/internal/utils" 12 | ) 13 | 14 | const ConfigFileName string = "config.toml" 15 | const ConfigDirName string = "nutmix" 16 | const LogFileName string = "nutmix.log" 17 | 18 | func getConfigFile() ([]byte, error) { 19 | dir, err := os.UserConfigDir() 20 | 21 | if err != nil { 22 | return []byte{}, fmt.Errorf("os.UserHomeDir(), %w", err) 23 | } 24 | 25 | var pathToProjectDir = dir + "/" + ConfigDirName 26 | var pathToProjectConfigFile = pathToProjectDir + "/" + ConfigFileName 27 | err = utils.CreateDirectoryAndPath(pathToProjectDir, ConfigFileName) 28 | 29 | if err != nil { 30 | return []byte{}, fmt.Errorf("utils.CreateDirectoryAndPath(pathToProjectDir, ConfigFileName), %w", err) 31 | } 32 | 33 | // Manipulate Config file and parse 34 | return os.ReadFile(pathToProjectConfigFile) 35 | } 36 | 37 | // will not look for os.variable config only file config 38 | func SetUpConfigDB(db database.MintDB) (utils.Config, error) { 39 | 40 | var config utils.Config 41 | // check if config in db exists if it doesn't check for config file or set default 42 | config, err := db.GetConfig() 43 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 44 | return config, fmt.Errorf("db.GetConfig(), %w", err) 45 | } 46 | 47 | if errors.Is(err, sql.ErrNoRows) { 48 | // check if config file exists 49 | file, err := getConfigFile() 50 | if err != nil { 51 | return config, fmt.Errorf("getConfigFile(), %w", err) 52 | } 53 | 54 | err = toml.Unmarshal(file, &config) 55 | if err != nil { 56 | return config, fmt.Errorf("toml.Unmarshal(buf,&config ), %w", err) 57 | } 58 | 59 | switch { 60 | 61 | // if no config set default to toml 62 | case (len(config.NETWORK) == 0 && len(config.MINT_LIGHTNING_BACKEND) == 0): 63 | config.Default() 64 | 65 | default: 66 | fmt.Println("running default") 67 | 68 | // if valid config value exists use those 69 | } 70 | 71 | // if the file config is set use that to set, if nothing is set do default 72 | // write to config db 73 | err = db.SetConfig(config) 74 | if err != nil { 75 | return config, fmt.Errorf("db.SetConfig(config) %w", err) 76 | } 77 | } 78 | 79 | return config, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/routes/admin/static/layout.css: -------------------------------------------------------------------------------- 1 | /* Main Layout */ 2 | .app-layout { 3 | display: flex; 4 | min-height: 100vh; 5 | } 6 | 7 | /* Sidebar */ 8 | .sidebar { 9 | width: 220px; 10 | background-color: var(--bg-sidebar); 11 | border-right: 1px solid var(--border-secondary); 12 | padding: var(--spacing-lg); 13 | display: flex; 14 | flex-direction: column; 15 | position: fixed; 16 | height: 100vh; 17 | overflow-y: auto; 18 | } 19 | 20 | .sidebar-logo { 21 | display: flex; 22 | align-items: center; 23 | gap: var(--spacing-sm); 24 | padding: var(--spacing-md) 0; 25 | margin-bottom: var(--spacing-lg); 26 | } 27 | 28 | .sidebar-logo-icon { 29 | width: 32px; 30 | height: 32px; 31 | } 32 | 33 | .sidebar-logo-text { 34 | font-size: var(--font-size-lg); 35 | font-weight: var(--font-weight-bold); 36 | color: var(--text-primary); 37 | } 38 | 39 | /* Navigation */ 40 | .nav-menu { 41 | display: flex; 42 | flex-direction: column; 43 | gap: var(--spacing-xs); 44 | } 45 | 46 | .nav-item { 47 | display: flex; 48 | align-items: center; 49 | gap: var(--spacing-md); 50 | padding: var(--spacing-md); 51 | border-radius: var(--radius-md); 52 | color: var(--text-secondary); 53 | text-decoration: none; 54 | transition: all var(--transition-base); 55 | cursor: pointer; 56 | } 57 | 58 | .nav-item:hover { 59 | background-color: var(--bg-card); 60 | color: var(--text-primary); 61 | } 62 | 63 | .nav-item.active { 64 | background-color: var(--bg-card); 65 | color: var(--text-primary); 66 | } 67 | 68 | .nav-item-icon { 69 | width: 20px; 70 | height: 20px; 71 | opacity: 0.7; 72 | } 73 | 74 | .nav-item.active .nav-item-icon { 75 | opacity: 1; 76 | } 77 | 78 | /* Main Content */ 79 | .main-content { 80 | flex: 1; 81 | margin-left: 220px; 82 | padding: var(--spacing-xl); 83 | background-color: var(--bg-primary); 84 | } 85 | 86 | /* Header */ 87 | .header { 88 | display: flex; 89 | justify-content: space-between; 90 | align-items: center; 91 | margin-bottom: var(--spacing-xl); 92 | } 93 | 94 | .header-title { 95 | font-size: var(--font-size-xl); 96 | font-weight: var(--font-weight-semibold); 97 | color: var(--text-primary); 98 | } 99 | 100 | .header-actions { 101 | display: flex; 102 | align-items: center; 103 | gap: var(--spacing-md); 104 | } 105 | -------------------------------------------------------------------------------- /test_calls.txt: -------------------------------------------------------------------------------- 1 | Mint payment request quote 2 | 3 | curl --request POST --url http://localhost:8080/v1/mint/quote/bolt11 \ 4 | --header 'Content-Type: application/json' \ 5 | --data '{ 6 | "amount" : 10, 7 | "unit": "sats" 8 | }' 9 | 10 | 11 | request mint tokens 12 | 13 | curl --request POST --url http://localhost:8080/v1/mint/bolt11 \ 14 | -H "Content-Type: application/json" -d \ 15 | '{ 16 | "quote": "638b1f85-5157-4cb7-954a-b8908aff9f63", 17 | "outputs": [ 18 | { 19 | "amount": 8, 20 | "id": "009a1f293253e41e", 21 | "B_": "035015e6d7ade60ba8426cefaf1832bbd27257636e44a76b922d78e79b47cb689d" 22 | }, 23 | { 24 | "amount": 2, 25 | "id": "009a1f293253e41e", 26 | "B_": "0288d7649652d0a83fc9c966c969fb217f15904431e61a44b14999fabc1b5d9ac6" 27 | } 28 | ] 29 | }' 30 | 31 | request unauthorized swap 32 | curl --request POST --url http://localhost:8080/v1/swap --header 'Content-Type: application/json' -d \ 33 | '{ 34 | "inputs": 35 | [ 36 | { 37 | "amount": 2, 38 | "id": "009a1f293253e41e", 39 | "secret": "407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", 40 | "C": "02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea" 41 | } 42 | ], 43 | "outputs": 44 | [ 45 | { 46 | "amount": 2, 47 | "id": "009a1f293253e41e", 48 | "B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239" 49 | } 50 | ], 51 | }' 52 | 53 | curl --request POST --url http://localhost:8080/v1/swap \ 54 | -H "Content-Type: application/json" -d \ 55 | '{ 56 | "inputs": 57 | [ 58 | { 59 | "amount": 2, 60 | "id": "009a1f293253e41e", 61 | "secret": "407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", 62 | "C": "02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea" 63 | } 64 | ], 65 | "outputs": 66 | [ 67 | { 68 | "amount": 2, 69 | "id": "009a1f293253e41e", 70 | "B_": "02634a2c2b34bec9e8a4aba4361f6bf202d7fa2365379b0840afe249a7a9d71239" 71 | } 72 | ], 73 | }' 74 | 75 | -------------------------------------------------------------------------------- /internal/signer/remote_signer/util.go: -------------------------------------------------------------------------------- 1 | package remotesigner 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "encoding/hex" 7 | "log" 8 | "os" 9 | 10 | "github.com/lescuer97/nutmix/internal/signer" 11 | "google.golang.org/grpc/credentials" 12 | ) 13 | 14 | func GetTlsSecurityCredential() (credentials.TransportCredentials, error) { 15 | 16 | tlsCertPath := os.Getenv("SIGNER_CLIENT_TLS_CERT") 17 | if tlsCertPath == "" { 18 | log.Panic("SIGNER_CLIENT_TLS_KEY path not available.") 19 | } 20 | tlsKeyPath := os.Getenv("SIGNER_CLIENT_TLS_KEY") 21 | if tlsKeyPath == "" { 22 | log.Panic("SIGNER_CLIENT_TLS_CERT path not available.") 23 | } 24 | caCertPath := os.Getenv("SIGNER_CA_CERT") 25 | 26 | // Load server certificate and key 27 | serverCert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath) 28 | if err != nil { 29 | log.Fatalf("Failed to load server cert: %v", err) 30 | } 31 | 32 | certPool := x509.NewCertPool() 33 | if caCertPath != "" { 34 | // Load CA certificate 35 | caCert, err := os.ReadFile(caCertPath) 36 | if err != nil { 37 | log.Fatalf("Failed to load CA cert: %v", err) 38 | } 39 | 40 | // Create a certificate pool and add the CA certificate 41 | if !certPool.AppendCertsFromPEM(caCert) { 42 | log.Fatal("Failed to add CA certificate to pool") 43 | } 44 | } 45 | 46 | // Create TLS configuration 47 | tlsConfig := &tls.Config{ 48 | Certificates: []tls.Certificate{serverCert}, 49 | ClientAuth: tls.RequireAndVerifyClientCert, // Require client certificate 50 | ClientCAs: certPool, // Verify client certificate against this CA 51 | } 52 | 53 | // Create the TLS credentials 54 | creds := credentials.NewTLS(tlsConfig) 55 | return creds, nil 56 | 57 | } 58 | func OrderKeysetByUnit(keysets []MintPublicKeyset) signer.GetKeysResponse { 59 | var typesOfUnits = make(map[string][]MintPublicKeyset) 60 | for _, keyset := range keysets { 61 | if len(typesOfUnits[keyset.Unit]) == 0 { 62 | typesOfUnits[keyset.Unit] = append(typesOfUnits[keyset.Unit], keyset) 63 | continue 64 | } else { 65 | typesOfUnits[keyset.Unit] = append(typesOfUnits[keyset.Unit], keyset) 66 | } 67 | } 68 | res := signer.GetKeysResponse{} 69 | res.Keysets = []signer.KeysetResponse{} 70 | for _, unitKeysets := range typesOfUnits { 71 | for _, mintKey := range unitKeysets { 72 | keyset := signer.KeysetResponse{} 73 | keyset.Id = hex.EncodeToString(mintKey.Id) 74 | keyset.Unit = mintKey.Unit 75 | keyset.Keys = mintKey.Keys 76 | res.Keysets = append(res.Keysets, keyset) 77 | } 78 | } 79 | return res 80 | } 81 | -------------------------------------------------------------------------------- /cmd/nutmix/info_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/lescuer97/nutmix/api/cashu" 13 | "github.com/lescuer97/nutmix/internal/database" 14 | "github.com/lescuer97/nutmix/internal/mint" 15 | "github.com/lescuer97/nutmix/internal/utils" 16 | "github.com/testcontainers/testcontainers-go" 17 | "github.com/testcontainers/testcontainers-go/modules/postgres" 18 | "github.com/testcontainers/testcontainers-go/wait" 19 | ) 20 | 21 | func TestMintInfo(t *testing.T) { 22 | const testVersion = "test-version" 23 | // Mock the version 24 | utils.AppVersion = testVersion 25 | 26 | const posgrespassword = "password" 27 | const postgresuser = "user" 28 | ctx := context.Background() 29 | 30 | postgresContainer, err := postgres.Run(ctx, "postgres:16.2", 31 | postgres.WithDatabase("postgres"), 32 | postgres.WithUsername(postgresuser), 33 | postgres.WithPassword(posgrespassword), 34 | testcontainers.WithWaitStrategy( 35 | wait.ForLog("database system is ready to accept connections"). 36 | WithOccurrence(2). 37 | WithStartupTimeout(5*time.Second)), 38 | ) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | connUri, err := postgresContainer.ConnectionString(ctx) 44 | if err != nil { 45 | t.Fatal(fmt.Errorf("failed to get connection string: %w", err)) 46 | } 47 | 48 | t.Setenv("DATABASE_URL", connUri) 49 | t.Setenv("MINT_PRIVATE_KEY", MintPrivateKey) 50 | t.Setenv("MINT_LIGHTNING_BACKEND", "FakeWallet") 51 | t.Setenv(mint.NETWORK_ENV, "regtest") 52 | 53 | ctx = context.WithValue(ctx, ctxKeyNetwork, os.Getenv(mint.NETWORK_ENV)) 54 | ctx = context.WithValue(ctx, ctxKeyLightningBackend, os.Getenv(mint.MINT_LIGHTNING_BACKEND_ENV)) 55 | ctx = context.WithValue(ctx, ctxKeyDatabaseURL, os.Getenv(database.DATABASE_URL_ENV)) 56 | ctx = context.WithValue(ctx, ctxKeyNetwork, os.Getenv(mint.NETWORK_ENV)) 57 | 58 | router, _ := SetupRoutingForTesting(ctx, false) 59 | 60 | req := httptest.NewRequest("GET", "/v1/info", nil) 61 | 62 | w := httptest.NewRecorder() 63 | 64 | router.ServeHTTP(w, req) 65 | 66 | if w.Code != 200 { 67 | t.Errorf("Expected status code 200, got %d", w.Code) 68 | } 69 | 70 | var mintInfo cashu.GetInfoResponse 71 | err = json.Unmarshal(w.Body.Bytes(), &mintInfo) 72 | if err != nil { 73 | t.Errorf("Error unmarshalling response: %v", err) 74 | } 75 | 76 | if mintInfo.Version != "nutmix/"+testVersion { 77 | t.Errorf("Incorrect version %v", mintInfo.Version) 78 | } 79 | 80 | if mintInfo.Pubkey != "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" { 81 | t.Errorf("Incorrect Pubkey %v", mintInfo.Pubkey) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/routes/admin/static/header.css: -------------------------------------------------------------------------------- 1 | /* ============================================ 2 | DASHBOARD HEADER 3 | ============================================ */ 4 | 5 | .dashboard-header { 6 | display: flex; 7 | align-items: center; 8 | justify-content: space-between; 9 | padding: var(--space-4) var(--space-8); 10 | background: var(--bg-card); 11 | border-bottom: 1px solid var(--border-primary); 12 | position: sticky; 13 | top: 0; 14 | z-index: 100; 15 | } 16 | 17 | .dashboard-title { 18 | font-size: var(--text-xl); 19 | font-weight: var(--font-semibold); 20 | color: var(--text-primary); 21 | margin: 0; 22 | text-transform: uppercase; 23 | letter-spacing: 0.05em; 24 | } 25 | 26 | .dashboard-title a { 27 | color: var(--text-primary); 28 | text-decoration: none; 29 | transition: color var(--transition-base); 30 | } 31 | 32 | .dashboard-title a:hover { 33 | color: var(--accent-cyan); 34 | } 35 | 36 | .dashboard-nav { 37 | display: flex; 38 | gap: var(--space-2); 39 | align-items: center; 40 | } 41 | 42 | .nav-tab { 43 | display: inline-flex; 44 | align-items: center; 45 | justify-content: center; 46 | padding: var(--space-3) var(--space-5); 47 | font-size: var(--text-sm); 48 | font-weight: var(--font-semibold); 49 | color: var(--text-secondary); 50 | text-decoration: none; 51 | border: none; 52 | background: transparent; 53 | transition: color var(--transition-base); 54 | position: relative; 55 | text-transform: uppercase; 56 | letter-spacing: 0.08em; 57 | } 58 | 59 | .nav-tab:hover { 60 | color: var(--text-primary); 61 | } 62 | .logout-button { 63 | color: var(--accent-red); 64 | } 65 | 66 | /* Active tab styles using CSS-only approach - color only */ 67 | body[data-route="stats"] .nav-tab[data-tab="stats"], 68 | body[data-route="lightning"] .nav-tab[data-tab="lightning"], 69 | body[data-route="settings"] .nav-tab[data-tab="settings"], 70 | body[data-route="liquidity"] .nav-tab[data-tab="liquidity"], 71 | body[data-route="keysets"] .nav-tab[data-tab="keysets"] { 72 | color: var(--accent-cyan); 73 | } 74 | 75 | /* Active tab indicator - using border-bottom instead of ::after for sharper look */ 76 | 77 | /* Responsive design */ 78 | @media (max-width: 768px) { 79 | .dashboard-header { 80 | flex-direction: column; 81 | gap: var(--space-4); 82 | padding: var(--space-4); 83 | } 84 | 85 | .dashboard-title { 86 | font-size: var(--text-lg); 87 | } 88 | 89 | .dashboard-nav { 90 | width: 100%; 91 | justify-content: center; 92 | flex-wrap: wrap; 93 | } 94 | 95 | .nav-tab { 96 | flex: 1; 97 | min-width: 80px; 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /internal/routes/admin/static/button.css: -------------------------------------------------------------------------------- 1 | /* ============================================ 2 | BUTTONS 3 | ============================================ */ 4 | 5 | /* Base Button */ 6 | .btn { 7 | display: inline-flex; 8 | align-items: center; 9 | justify-content: center; 10 | gap: var(--space-2); 11 | padding: var(--space-2) var(--space-5); 12 | font-size: var(--text-sm); 13 | font-weight: var(--font-semibold); 14 | letter-spacing: 0.02em; 15 | text-transform: uppercase; 16 | border-radius: var(--radius-md); 17 | transition: all var(--transition-base); 18 | white-space: nowrap; 19 | min-height: 40px; 20 | } 21 | 22 | /* Primary Button (Cyan/Teal) */ 23 | .btn-primary { 24 | background: var(--accent-cyan); 25 | color: #000000; 26 | border: 1px solid var(--accent-cyan); 27 | } 28 | 29 | .btn-primary:hover { 30 | background: var(--accent-cyan-dim); 31 | border-color: var(--accent-cyan-dim); 32 | box-shadow: var(--shadow-glow-cyan); 33 | } 34 | 35 | .btn-primary:active { 36 | transform: translateY(1px); 37 | } 38 | 39 | /* Secondary Button (Outline/Ghost) */ 40 | .btn-secondary { 41 | background: transparent; 42 | color: var(--text-primary); 43 | border: 1px solid var(--border-secondary); 44 | } 45 | 46 | .btn-secondary:hover { 47 | background: rgba(255, 255, 255, 0.05); 48 | border-color: var(--border-active); 49 | } 50 | 51 | /* Icon Button (Arrow buttons in cards) */ 52 | .btn-icon { 53 | width: 36px; 54 | height: 36px; 55 | min-height: auto; 56 | padding: 0; 57 | background: rgba(255, 255, 255, 0.05); 58 | border: 1px solid var(--border-primary); 59 | border-radius: var(--radius-md); 60 | color: var(--text-secondary); 61 | } 62 | 63 | .btn-icon:hover { 64 | background: rgba(255, 255, 255, 0.1); 65 | border-color: var(--border-secondary); 66 | color: var(--text-primary); 67 | } 68 | 69 | .btn-icon svg, 70 | .btn-icon .icon { 71 | width: 16px; 72 | height: 16px; 73 | } 74 | 75 | /* Button with Arrow */ 76 | .btn .arrow-icon { 77 | width: 14px; 78 | height: 14px; 79 | transition: transform var(--transition-base); 80 | } 81 | 82 | .btn:hover .arrow-icon { 83 | transform: translateX(2px); 84 | } 85 | 86 | /* Small Button */ 87 | .btn-sm { 88 | padding: var(--space-1) var(--space-3); 89 | font-size: var(--text-xs); 90 | min-height: 32px; 91 | } 92 | 93 | /* Disabled State */ 94 | .btn:disabled, 95 | .btn[disabled] { 96 | opacity: 0.5; 97 | cursor: not-allowed; 98 | pointer-events: none; 99 | filter: grayscale(100%); 100 | } 101 | 102 | /* Shake Animation */ 103 | @keyframes shake { 104 | 0%, 100% { transform: translateX(0); } 105 | 25% { transform: translateX(-4px); } 106 | 75% { transform: translateX(4px); } 107 | } 108 | 109 | .btn:active { 110 | animation: shake 0.15s ease-in-out; 111 | } 112 | -------------------------------------------------------------------------------- /internal/routes/admin/static/src/modules/auth.js: -------------------------------------------------------------------------------- 1 | // Authentication module for NIP-07 login form 2 | /** 3 | * @typedef {Object} UnsignedNostrEvent 4 | * @property {number} created_at - should be a unix timestamp 5 | * @property {number} kind 6 | * @property {Array[][]} tags 7 | * @property {string} content 8 | */ 9 | /** 10 | * @typedef {Object} SignedNostrEvent 11 | * @property {number} created_at - should be a unix timestamp 12 | * @property {number} kind 13 | * @property {Array[][]} tags 14 | * @property {string} content 15 | * @property {string} id 16 | * @property {string} sig 17 | */ 18 | 19 | /** 20 | * Initialize NIP-07 login form handling 21 | */ 22 | export function initAuth() { 23 | let nip07form = document.getElementById("nip07-form"); 24 | // sig nonce sent by the server, in case of success navigate. if an error occurs show an error 25 | nip07form?.addEventListener("submit", (e) => { 26 | e.preventDefault(); 27 | 28 | // Check if NIP-07 extension is available 29 | if (!window.nostr) { 30 | window.htmx.swap( 31 | "#notifications", 32 | '
You don\'t have a browser extension installed
', 33 | { swapStyle: "innerHTML" } 34 | ); 35 | return; 36 | } 37 | 38 | let formValues = Object.values(e.target).reduce((obj, field) => { 39 | obj[field.name] = field.value; 40 | return obj; 41 | }, {}); 42 | 43 | /** @type {UnsignedNostrEvent}*/ 44 | const eventToSign = { 45 | created_at: Math.floor(Date.now() / 1000), 46 | kind: 27235, 47 | tags: [], 48 | content: formValues.passwordNonce, 49 | }; 50 | 51 | window.nostr 52 | .signEvent(eventToSign) 53 | .then( 54 | ( 55 | /** 56 | @type {SignedNostrEvent} 57 | */ signedEvent 58 | ) => { 59 | const loginRequest = new Request("/admin/login", { 60 | method: "POST", 61 | body: JSON.stringify(signedEvent), 62 | }); 63 | 64 | fetch(loginRequest) 65 | .then(async (res) => { 66 | 67 | const text = await res.text(); 68 | if (res.ok) { 69 | const targetHeader = res.headers.get("HX-RETARGET"); 70 | if (targetHeader) { 71 | window.htmx.swap(`${targetHeader}`, text, { swapStyle: "innerHTML" }); 72 | return 73 | } 74 | 75 | window.location.href = "/admin"; 76 | } else { 77 | const targetHeader = res.headers.get("HX-RETARGET"); 78 | if (window.htmx && targetHeader) { 79 | window.htmx.swap(`#${targetHeader}`, text, { swapStyle: "innerHTML" }); 80 | } 81 | } 82 | }) 83 | .catch((err) => { 84 | console.log("Error message"); 85 | console.log({ err }); 86 | }); 87 | } 88 | ) 89 | .catch((err) => { 90 | console.log({ err }); 91 | }); 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /internal/database/mock_db/admin.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/jackc/pgx/v5" 7 | "github.com/lescuer97/nutmix/api/cashu" 8 | "github.com/lescuer97/nutmix/internal/database" 9 | "github.com/lescuer97/nutmix/internal/utils" 10 | ) 11 | 12 | func (m *MockDB) SaveNostrAuth(auth database.NostrLoginAuth) error { 13 | return nil 14 | 15 | } 16 | 17 | func (m *MockDB) UpdateNostrAuthActivation(tx pgx.Tx, nonce string, activated bool) error { 18 | return nil 19 | } 20 | 21 | func (m *MockDB) GetNostrAuth(tx pgx.Tx, nonce string) (database.NostrLoginAuth, error) { 22 | var seeds []database.NostrLoginAuth 23 | for i := 0; i < len(m.NostrAuth); i++ { 24 | 25 | if m.Seeds[i].Unit == nonce { 26 | seeds = append(seeds, m.NostrAuth[i]) 27 | 28 | } 29 | 30 | } 31 | return seeds[0], nil 32 | 33 | } 34 | 35 | func (m *MockDB) GetMintMeltBalanceByTime(time int64) (database.MintMeltBalance, error) { 36 | var mintmeltbalance database.MintMeltBalance 37 | 38 | for i := 0; i < len(m.MeltRequest); i++ { 39 | if m.MeltRequest[i].State == cashu.ISSUED || m.MeltRequest[i].State == cashu.PAID { 40 | mintmeltbalance.Melt = append(mintmeltbalance.Melt, m.MeltRequest[i]) 41 | 42 | } 43 | 44 | } 45 | 46 | for j := 0; j < len(m.MeltRequest); j++ { 47 | if m.MintRequest[j].State == cashu.ISSUED || m.MintRequest[j].State == cashu.PAID { 48 | mintmeltbalance.Mint = append(mintmeltbalance.Mint, m.MintRequest[j]) 49 | 50 | } 51 | 52 | } 53 | return mintmeltbalance, nil 54 | } 55 | func (m *MockDB) AddLiquiditySwap(tx pgx.Tx, swap utils.LiquiditySwap) error { 56 | m.LiquiditySwap = append(m.LiquiditySwap, swap) 57 | return nil 58 | 59 | } 60 | func (m *MockDB) ChangeLiquiditySwapState(tx pgx.Tx, id string, state utils.SwapState) error { 61 | var liquiditySwaps []utils.LiquiditySwap 62 | for i := 0; i < len(m.LiquiditySwap); i++ { 63 | if m.LiquiditySwap[i].Id == id { 64 | liquiditySwaps[i].State = state 65 | } 66 | 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (m *MockDB) GetLiquiditySwapById(tx pgx.Tx, id string) (utils.LiquiditySwap, error) { 73 | var liquiditySwaps []utils.LiquiditySwap 74 | for i := 0; i < len(m.LiquiditySwap); i++ { 75 | 76 | if m.LiquiditySwap[i].Id == id { 77 | liquiditySwaps = append(liquiditySwaps, m.LiquiditySwap[i]) 78 | 79 | } 80 | 81 | } 82 | 83 | return liquiditySwaps[0], nil 84 | } 85 | 86 | func (m *MockDB) GetAllLiquiditySwaps() ([]utils.LiquiditySwap, error) { 87 | return m.LiquiditySwap, nil 88 | } 89 | 90 | func (m *MockDB) GetLiquiditySwapsByStates(tx pgx.Tx, states []utils.SwapState) ([]string, error) { 91 | liquiditySwaps := make([]string, 0) 92 | for i := 0; i < len(m.LiquiditySwap); i++ { 93 | if slices.Contains(states, m.LiquiditySwap[i].State) { 94 | liquiditySwaps = append(liquiditySwaps, m.LiquiditySwap[i].Id) 95 | } 96 | 97 | } 98 | 99 | return liquiditySwaps, nil 100 | 101 | } 102 | -------------------------------------------------------------------------------- /internal/routes/admin/static/expansion.css: -------------------------------------------------------------------------------- 1 | /* Expansion Panel Styles */ 2 | 3 | .expansion-panel { 4 | background: var(--bg-card); 5 | border: 1px solid var(--border-primary); 6 | border-radius: var(--radius-lg); 7 | transition: all var(--transition-base); 8 | margin-bottom: var(--space-4); 9 | overflow: hidden; 10 | } 11 | 12 | .expansion-panel:hover { 13 | border-color: var(--border-secondary); 14 | } 15 | 16 | .expansion-panel[open] { 17 | background: var(--bg-card); 18 | } 19 | 20 | /* Summary Styling */ 21 | .expansion-summary { 22 | list-style: none; 23 | padding: var(--space-4) var(--space-5); 24 | cursor: pointer; 25 | user-select: none; 26 | position: relative; 27 | transition: background-color var(--transition-base); 28 | } 29 | 30 | .expansion-summary::-webkit-details-marker { 31 | display: none; 32 | } 33 | 34 | .expansion-summary:hover { 35 | background: rgba(255, 255, 255, 0.02); 36 | } 37 | 38 | /* Header Layout */ 39 | .expansion-header { 40 | display: flex; 41 | align-items: center; 42 | justify-content: space-between; 43 | gap: var(--space-4); 44 | } 45 | 46 | .expansion-title-group { 47 | display: flex; 48 | flex-direction: column; 49 | gap: var(--space-1); 50 | flex: 1; 51 | } 52 | 53 | .expansion-title { 54 | font-size: var(--text-lg); 55 | font-weight: var(--font-medium); 56 | color: var(--text-primary); 57 | margin: 0; 58 | } 59 | 60 | .expansion-description { 61 | font-size: var(--text-sm); 62 | color: var(--text-secondary); 63 | margin: 0; 64 | } 65 | 66 | .expansion-controls { 67 | display: flex; 68 | align-items: center; 69 | gap: var(--space-3); 70 | } 71 | 72 | .expansion-extra { 73 | display: flex; 74 | align-items: center; 75 | } 76 | 77 | /* Icon Animation */ 78 | .expansion-icon-wrapper { 79 | display: flex; 80 | align-items: center; 81 | justify-content: center; 82 | transition: all var(--transition-base); 83 | } 84 | 85 | .expansion-icon { 86 | width: 20px; 87 | height: 20px; 88 | transition: transform var(--transition-base); 89 | } 90 | 91 | details[open] .expansion-icon { 92 | transform: rotate(180deg); 93 | } 94 | 95 | details[open] .expansion-icon-wrapper { 96 | background: rgba(255, 255, 255, 0.1); 97 | border-color: var(--border-secondary); 98 | color: var(--text-primary); 99 | } 100 | 101 | /* Content Animation */ 102 | .expansion-content { 103 | border-top: 1px solid var(--border-primary); 104 | animation: slideDown 0.3s ease-out forwards; 105 | transform-origin: top; 106 | } 107 | 108 | .expansion-content-inner { 109 | padding: var(--space-5); 110 | } 111 | 112 | @keyframes slideDown { 113 | 0% { 114 | opacity: 0; 115 | transform: translateY(-10px); 116 | } 117 | 100% { 118 | opacity: 1; 119 | transform: translateY(0); 120 | } 121 | } 122 | 123 | -------------------------------------------------------------------------------- /internal/utils/proofs_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lescuer97/nutmix/api/cashu" 7 | ) 8 | 9 | func setListofEmptyBlindMessages(amounts int) []cashu.BlindedMessage { 10 | var messages []cashu.BlindedMessage 11 | for i := 0; i < amounts; i++ { 12 | message := cashu.BlindedMessage{ 13 | Id: "mockid", 14 | Amount: 0, 15 | } 16 | messages = append(messages, message) 17 | 18 | } 19 | 20 | return messages 21 | } 22 | func TestGetChangeWithEnoughBlindMessages(t *testing.T) { 23 | 24 | emptyBlindMessages := setListofEmptyBlindMessages(10) 25 | 26 | // create change for value of 2 27 | change := GetMessagesForChange(2, emptyBlindMessages) 28 | 29 | if len(change) != 1 { 30 | t.Errorf("Incorrect size for change slice %v, should be 1", len(change)) 31 | 32 | } 33 | 34 | if change[0].Amount != 2 { 35 | t.Errorf("Incorrect amount for change slice %v, should be 2", change[0].Amount) 36 | } 37 | 38 | // create change for a 0 amount 39 | change = GetMessagesForChange(0, emptyBlindMessages) 40 | 41 | if len(change) != 0 { 42 | t.Errorf("Incorrect size for change slice %v, should be 0", len(change)) 43 | } 44 | 45 | } 46 | 47 | func TestGetChangeWithOutEnoughBlindMessages(t *testing.T) { 48 | 49 | emptyBlindMessages := setListofEmptyBlindMessages(1) 50 | 51 | // create change for value of 2 52 | change := GetMessagesForChange(10, emptyBlindMessages) 53 | 54 | if len(change) != 1 { 55 | t.Errorf("Incorrect size for change slice %v, should be 1", len(change)) 56 | } 57 | 58 | if change[0].Amount != 2 { 59 | t.Errorf("Incorrect amount for change slice %v, should be 2", change[0].Amount) 60 | } 61 | 62 | } 63 | 64 | func MakeListofMockProofs(amounts int) []cashu.Proof { 65 | 66 | var proofs []cashu.Proof 67 | for i := 0; i < amounts; i++ { 68 | proof := cashu.Proof{ 69 | Id: "mockid", 70 | Amount: 0, 71 | } 72 | proofs = append(proofs, proof) 73 | 74 | } 75 | 76 | return proofs 77 | } 78 | 79 | func TestGetValuesFromProofs(t *testing.T) { 80 | 81 | listOfProofs := cashu.Proofs{ 82 | { 83 | Id: "mockid", 84 | Amount: 2, 85 | Secret: "mockSecret", 86 | }, 87 | { 88 | Id: "mockid", 89 | Amount: 6, 90 | Secret: "mockSecret2", 91 | }, 92 | } 93 | 94 | TotalAmount, secretsList, err := GetAndCalculateProofsValues(&listOfProofs) 95 | if err != nil { 96 | t.Fatal("GetAndCalculateProofsValues(&listOfProofs)") 97 | } 98 | 99 | if TotalAmount != 8 { 100 | t.Errorf("Incorrect total amount %v. Should be 8", TotalAmount) 101 | } 102 | 103 | if secretsList[0].ToHex() != "02aa4a2c024e41bd87e8c2758d5a7c2d81e09afe52f67fc8a69768bd73d515e28f" { 104 | t.Errorf("Should be mock secret %v. Should be 8", TotalAmount) 105 | } 106 | if listOfProofs[0].Y.ToHex() != "02aa4a2c024e41bd87e8c2758d5a7c2d81e09afe52f67fc8a69768bd73d515e28f" { 107 | t.Errorf("Incorrect Y: %v. ", listOfProofs[0].Y) 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /api/cashu/keys.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "math" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/decred/dcrd/dcrec/secp256k1/v4" 11 | "github.com/tyler-smith/go-bip32" 12 | ) 13 | 14 | func DeriveKeysetId(keysets []*secp256k1.PublicKey) (string, error) { 15 | concatBinaryArray := []byte{} 16 | for _, pubkey := range keysets { 17 | if pubkey == nil { 18 | panic("pubkey should have never been nil at this time") 19 | } 20 | concatBinaryArray = append(concatBinaryArray, pubkey.SerializeCompressed()...) 21 | } 22 | hashedKeysetId := sha256.Sum256(concatBinaryArray) 23 | hex := hex.EncodeToString(hashedKeysetId[:]) 24 | 25 | return "00" + hex[:14], nil 26 | } 27 | 28 | func DeriveKeysetIdV2(pubKeysArray []*secp256k1.PublicKey, unit Unit, finalExpiry *time.Time) string { 29 | var keysetIDBytes []byte 30 | 31 | for _, key := range pubKeysArray { 32 | if key == nil { 33 | panic("pubkey should have never been nil at this time") 34 | } 35 | keysetIDBytes = append(keysetIDBytes, key.SerializeCompressed()...) 36 | } 37 | 38 | keysetIDBytes = append(keysetIDBytes, []byte("unit:"+unit.String())...) 39 | if finalExpiry != nil { 40 | keysetIDBytes = append(keysetIDBytes, []byte("final_expiry:"+strconv.Itoa(int(finalExpiry.Unix())))...) 41 | } 42 | hash := sha256.Sum256(keysetIDBytes) 43 | return "01" + hex.EncodeToString(hash[:]) 44 | } 45 | 46 | func GenerateKeysets(versionKey *bip32.Key, values []uint64, seed Seed) ([]MintKey, error) { 47 | var keysets []MintKey 48 | 49 | // Get the current time 50 | currentTime := time.Now() 51 | 52 | // Format the time as a string 53 | formattedTime := currentTime.Unix() 54 | for i, value := range values { 55 | // uses the value it represents to derive the key 56 | childKey, err := versionKey.NewChildKey(uint32(i)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | privKey := secp256k1.PrivKeyFromBytes(childKey.Key) 61 | 62 | keyset := MintKey{ 63 | Id: seed.Id, 64 | Active: seed.Active, 65 | Unit: seed.Unit, 66 | Amount: value, 67 | PrivKey: privKey, 68 | CreatedAt: formattedTime, 69 | InputFeePpk: seed.InputFeePpk, 70 | } 71 | 72 | keysets = append(keysets, keyset) 73 | } 74 | 75 | return keysets, nil 76 | } 77 | 78 | const MaxKeysetAmount int = 64 79 | 80 | func GetAmountsForKeysets() []uint64 { 81 | keys := make([]uint64, 0) 82 | 83 | for i := 0; i < MaxKeysetAmount; i++ { 84 | keys = append(keys, uint64(math.Pow(2, float64(i)))) 85 | } 86 | return keys 87 | } 88 | 89 | // Given an amount, it returns list of amounts e.g 13 -> [1, 4, 8] 90 | // that can be used to build blinded messages or split operations. 91 | // from nutshell implementation 92 | func AmountSplit(amount uint64) []uint64 { 93 | rv := make([]uint64, 0) 94 | for pos := 0; amount > 0; pos++ { 95 | if amount&1 == 1 { 96 | rv = append(rv, 1<>= 1 99 | } 100 | return rv 101 | } 102 | -------------------------------------------------------------------------------- /internal/routes/admin/mint-activity.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/lescuer97/nutmix/api/cashu" 9 | "github.com/lescuer97/nutmix/internal/database" 10 | m "github.com/lescuer97/nutmix/internal/mint" 11 | "github.com/lescuer97/nutmix/internal/routes/admin/templates" 12 | "github.com/lescuer97/nutmix/internal/utils" 13 | ) 14 | 15 | func SwapsList(mint *m.Mint) gin.HandlerFunc { 16 | return func(c *gin.Context) { 17 | 18 | swaps, err := mint.MintDB.GetAllLiquiditySwaps() 19 | 20 | if err != nil { 21 | slog.Error( 22 | "mint.MintDB.GetAllLiquiditySwaps()", 23 | slog.String(utils.LogExtraInfo, err.Error())) 24 | 25 | err := RenderError(c, "There was an error getting mint activity") 26 | if err != nil { 27 | slog.Error("RenderError", slog.Any("error", err)) 28 | } 29 | return 30 | } 31 | 32 | ctx := context.Background() 33 | 34 | err = templates.ListOfSwaps(swaps).Render(ctx, c.Writer) 35 | if err != nil { 36 | _ = c.Error(err) 37 | c.Status(400) 38 | return 39 | } 40 | } 41 | } 42 | 43 | func SummaryComponent(mint *m.Mint, adminHandler *adminHandler) gin.HandlerFunc { 44 | return func(c *gin.Context) { 45 | // Parse time range from query params 46 | timeRange := c.Query("since") 47 | startTime, _ := parseTimeRange(timeRange) 48 | 49 | proofsCount, err := adminHandler.getProofsCountByKeyset(startTime) 50 | if err != nil { 51 | _ = c.Error(err) 52 | return 53 | } 54 | 55 | keysets, err := mint.Signer.GetKeysets() 56 | if err != nil { 57 | _ = c.Error(err) 58 | return 59 | } 60 | 61 | fees, err := fees(proofsCount, keysets.Keysets) 62 | if err != nil { 63 | _ = c.Error(err) 64 | return 65 | } 66 | 67 | lnBalance, err := mint.LightningBackend.WalletBalance() 68 | if err != nil { 69 | _ = c.Error(err) 70 | return 71 | } 72 | 73 | // Format the since date for display 74 | sinceDate := startTime.Format("Jan 2, 2006") 75 | if timeRange == "all" { 76 | sinceDate = "the beginning" 77 | } 78 | 79 | summary := templates.Summary{ 80 | LnBalance: lnBalance / 1000, 81 | FakeWallet: mint.Config.MINT_LIGHTNING_BACKEND == utils.FAKE_WALLET, 82 | Fees: fees, 83 | SinceDate: sinceDate, 84 | } 85 | 86 | err = templates.SummaryComponent(summary).Render(c.Request.Context(), c.Writer) 87 | if err != nil { 88 | _ = c.Error(err) 89 | return 90 | } 91 | } 92 | } 93 | 94 | func fees(proofs map[string]database.ProofsCountByKeyset, keysets []cashu.BasicKeysetResponse) (uint64, error) { 95 | totalFees := uint64(0) 96 | 97 | for _, keyset := range keysets { 98 | if keyset.Unit != cashu.AUTH.String() { 99 | for keysetId, proof := range proofs { 100 | if keyset.Id == keysetId { 101 | totalFees += uint64(proof.Count) * uint64(keyset.InputFeePpk) 102 | } 103 | } 104 | } 105 | 106 | } 107 | 108 | totalFees = (totalFees + 999) / 1000 109 | 110 | return totalFees, nil 111 | 112 | } 113 | -------------------------------------------------------------------------------- /internal/signer/utils.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | 7 | "github.com/lescuer97/nutmix/api/cashu" 8 | "github.com/tyler-smith/go-bip32" 9 | ) 10 | 11 | func OrderKeysetByUnit(keysets []cashu.MintKey) GetKeysResponse { 12 | var typesOfUnits = make(map[string][]cashu.MintKey) 13 | 14 | for _, keyset := range keysets { 15 | if len(typesOfUnits[keyset.Unit]) == 0 { 16 | typesOfUnits[keyset.Unit] = append(typesOfUnits[keyset.Unit], keyset) 17 | continue 18 | } else { 19 | typesOfUnits[keyset.Unit] = append(typesOfUnits[keyset.Unit], keyset) 20 | } 21 | } 22 | 23 | res := GetKeysResponse{} 24 | 25 | res.Keysets = []KeysetResponse{} 26 | 27 | for _, value := range typesOfUnits { 28 | var keysetResponse KeysetResponse 29 | keysetResponse.Id = value[0].Id 30 | keysetResponse.Unit = value[0].Unit 31 | keysetResponse.Keys = make(map[uint64]string) 32 | keysetResponse.InputFeePpk = value[0].InputFeePpk 33 | 34 | for _, keyset := range value { 35 | 36 | keysetResponse.Keys[keyset.Amount] = hex.EncodeToString(keyset.PrivKey.PubKey().SerializeCompressed()) 37 | } 38 | 39 | res.Keysets = append(res.Keysets, keysetResponse) 40 | } 41 | return res 42 | 43 | } 44 | func DeriveKeyset(mintKey *bip32.Key, seed cashu.Seed) ([]cashu.MintKey, error) { 45 | unit, err := cashu.UnitFromString(seed.Unit) 46 | if err != nil { 47 | return nil, fmt.Errorf("UnitFromString(seed.Unit) %w", err) 48 | } 49 | 50 | unitKey, err := mintKey.NewChildKey(uint32(unit.EnumIndex())) 51 | 52 | if err != nil { 53 | 54 | return nil, fmt.Errorf("mintKey.NewChildKey(uint32(unit.EnumIndex())). %w", err) 55 | } 56 | 57 | versionKey, err := unitKey.NewChildKey(uint32(seed.Version)) 58 | if err != nil { 59 | return nil, fmt.Errorf("mintKey.NewChildKey(uint32(seed.Version)) %w", err) 60 | } 61 | 62 | amounts := cashu.GetAmountsForKeysets() 63 | 64 | if unit == cashu.AUTH { 65 | amounts = []uint64{amounts[0]} 66 | } 67 | 68 | keyset, err := cashu.GenerateKeysets(versionKey, amounts, seed) 69 | if err != nil { 70 | return nil, fmt.Errorf(`GenerateKeysets(versionKey,GetAmountsForKeysets(), "", unit, 0) %w`, err) 71 | } 72 | 73 | return keyset, nil 74 | } 75 | 76 | func GetKeysetsFromSeeds(seeds []cashu.Seed, mintKey *bip32.Key) (map[string]cashu.MintKeysMap, map[string]cashu.MintKeysMap, error) { 77 | newKeysets := make(map[string]cashu.MintKeysMap) 78 | newActiveKeysets := make(map[string]cashu.MintKeysMap) 79 | 80 | for _, seed := range seeds { 81 | keysets, err := DeriveKeyset(mintKey, seed) 82 | if err != nil { 83 | return newKeysets, newActiveKeysets, fmt.Errorf("DeriveKeyset(mintKey, seed) %w", err) 84 | } 85 | 86 | mintkeyMap := make(cashu.MintKeysMap) 87 | for _, keyset := range keysets { 88 | mintkeyMap[keyset.Amount] = keyset 89 | } 90 | 91 | if seed.Active { 92 | newActiveKeysets[seed.Id] = mintkeyMap 93 | } 94 | 95 | newKeysets[seed.Id] = mintkeyMap 96 | } 97 | return newKeysets, newActiveKeysets, nil 98 | 99 | } 100 | -------------------------------------------------------------------------------- /cmd/nutmix/main_cache_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gin-contrib/cache/persistence" 12 | "github.com/gin-gonic/gin" 13 | "github.com/lescuer97/nutmix/internal/routes/middleware" 14 | ) 15 | 16 | func TestCacheMiddleware(t *testing.T) { 17 | gin.SetMode(gin.TestMode) 18 | 19 | store := persistence.NewInMemoryStore(1 * time.Minute) 20 | 21 | handerCallCount := 0 22 | mutex := &sync.Mutex{} 23 | 24 | r := gin.New() 25 | 26 | r.Use(middleware.CacheMiddleware(store)) 27 | 28 | r.POST("/v1/swap", func(c *gin.Context) { 29 | mutex.Lock() 30 | handerCallCount++ 31 | mutex.Unlock() 32 | 33 | if c.Query("fail") == "true" { 34 | c.JSON(http.StatusBadRequest, gin.H{"status": "failed"}) 35 | } else { 36 | c.JSON(http.StatusOK, gin.H{"status": "ok"}) 37 | } 38 | }) 39 | 40 | // Test case 1: Successful request should be cached 41 | t.Run("caches successful responses", func(t *testing.T) { 42 | handerCallCount = 0 // Reset counter 43 | 44 | body := `{"key":"value"}` 45 | 46 | // First request 47 | req1, _ := http.NewRequest("POST", "/v1/swap", bytes.NewBufferString(body)) 48 | w1 := httptest.NewRecorder() 49 | r.ServeHTTP(w1, req1) 50 | 51 | if w1.Code != http.StatusOK { 52 | t.Errorf("Expected status OK, got %d", w1.Code) 53 | } 54 | if handerCallCount != 1 { 55 | t.Errorf("Expected handler to be called once, got %d", handerCallCount) 56 | } 57 | 58 | // Second request - should be served from cache 59 | req2, _ := http.NewRequest("POST", "/v1/swap", bytes.NewBufferString(body)) 60 | w2 := httptest.NewRecorder() 61 | r.ServeHTTP(w2, req2) 62 | 63 | if w2.Code != http.StatusOK { 64 | t.Errorf("Expected status OK, got %d", w2.Code) 65 | } 66 | if handerCallCount != 1 { 67 | t.Errorf("Expected handler to be called once, got %d", handerCallCount) 68 | } 69 | }) 70 | 71 | // Test case 2: Failed request should not be cached 72 | t.Run("does not cache failed responses", func(t *testing.T) { 73 | handerCallCount = 0 // Reset counter 74 | 75 | body := `{"key":"failure"}` 76 | 77 | // First request 78 | req1, _ := http.NewRequest("POST", "/v1/swap?fail=true", bytes.NewBufferString(body)) 79 | w1 := httptest.NewRecorder() 80 | r.ServeHTTP(w1, req1) 81 | 82 | if w1.Code != http.StatusBadRequest { 83 | t.Errorf("Expected status Bad Request, got %d", w1.Code) 84 | } 85 | if handerCallCount != 1 { 86 | t.Errorf("Expected handler to be called once, got %d", handerCallCount) 87 | } 88 | 89 | // Second request - should not be cached 90 | req2, _ := http.NewRequest("POST", "/v1/swap?fail=true", bytes.NewBufferString(body)) 91 | w2 := httptest.NewRecorder() 92 | r.ServeHTTP(w2, req2) 93 | 94 | if w2.Code != http.StatusBadRequest { 95 | t.Errorf("Expected status Bad Request, got %d", w2.Code) 96 | } 97 | if handerCallCount != 2 { 98 | t.Errorf("Expected handler to be called twice, got %d", handerCallCount) 99 | } 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /api/cashu/swap.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type PostSwapRequest struct { 9 | Inputs Proofs `json:"inputs"` 10 | Outputs []BlindedMessage `json:"outputs"` 11 | } 12 | 13 | func (p *PostSwapRequest) ValidateSigflag() error { 14 | sigFlagValidation, err := checkForSigAll(p.Inputs) 15 | if err != nil { 16 | return fmt.Errorf("checkForSigAll(p.Inputs). %w", err) 17 | } 18 | if sigFlagValidation.sigFlag == SigAll { 19 | 20 | firstSpendCondition, err := p.Inputs[0].parseSpendCondition() 21 | if err != nil { 22 | return fmt.Errorf("p.Inputs[0].parseWitnessAndSecret(). %w", err) 23 | } 24 | firstWitness, err := p.Inputs[0].parseWitness() 25 | if err != nil { 26 | return fmt.Errorf("p.Inputs[0].parseWitnessAndSecret(). %w", err) 27 | } 28 | if firstSpendCondition == nil || firstWitness == nil { 29 | return ErrInvalidSpendCondition 30 | } 31 | 32 | if firstWitness.Signatures == nil { 33 | return ErrNoValidSignatures 34 | } 35 | 36 | // check tha conditions are met 37 | err = p.verifyConditions() 38 | if err != nil { 39 | return fmt.Errorf("p.verifyConditions(). %w", err) 40 | } 41 | 42 | // makes message 43 | msg := p.makeSigAllMsg() 44 | 45 | pubkeys, err := p.Inputs[0].PubkeysForVerification() 46 | if err != nil { 47 | return fmt.Errorf("p.Inputs[0].Pubkeys(). %w", err) 48 | } 49 | 50 | amountOfSigs, err := checkValidSignature(msg, pubkeys, firstWitness.Signatures) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if amountOfSigs >= sigFlagValidation.signaturesRequired { 56 | return nil 57 | } 58 | 59 | return ErrNotEnoughSignatures 60 | } 61 | return nil 62 | } 63 | 64 | func (p *PostSwapRequest) verifyConditions() error { 65 | firstProof := p.Inputs[0] 66 | firstSpendCondition, err := firstProof.parseSpendCondition() 67 | if err != nil { 68 | return nil 69 | } 70 | 71 | for _, proof := range p.Inputs { 72 | spendCondition, err := proof.parseSpendCondition() 73 | if err != nil { 74 | return nil 75 | } 76 | 77 | if spendCondition.Data.Data != firstSpendCondition.Data.Data { 78 | return fmt.Errorf("not same data field %w", ErrInvalidSpendCondition) 79 | } 80 | 81 | if string(spendCondition.Data.Tags.originalTag) != string(firstSpendCondition.Data.Tags.originalTag) { 82 | return fmt.Errorf("not same tags %w", ErrInvalidSpendCondition) 83 | } 84 | 85 | } 86 | return nil 87 | } 88 | 89 | // makeSigAllMsg creates the message for SIG_ALL signature verification 90 | // Format: secret_0 || C_0 || ... || secret_n || C_n || amount_0 || B_0 || ... || amount_m || B_m 91 | func (p *PostSwapRequest) makeSigAllMsg() string { 92 | message := "" 93 | for _, proof := range p.Inputs { 94 | message = message + proof.Secret + proof.C.String() 95 | } 96 | for _, blindMessage := range p.Outputs { 97 | message = message + strconv.FormatUint(blindMessage.Amount, 10) + blindMessage.B_.String() 98 | } 99 | return message 100 | } 101 | 102 | type PostSwapResponse struct { 103 | Signatures []BlindSignature `json:"signatures"` 104 | } 105 | -------------------------------------------------------------------------------- /internal/lightning/proto/cln_primitives.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package cln; 3 | option go_package = "./cln_grpc"; 4 | 5 | message Amount { 6 | uint64 msat = 1; 7 | } 8 | 9 | message AmountOrAll { 10 | oneof value { 11 | Amount amount = 1; 12 | bool all = 2; 13 | } 14 | } 15 | 16 | message AmountOrAny { 17 | oneof value { 18 | Amount amount = 1; 19 | bool any = 2; 20 | } 21 | } 22 | 23 | enum ChannelSide { 24 | LOCAL = 0; 25 | REMOTE = 1; 26 | } 27 | 28 | enum ChannelState { 29 | Openingd = 0; 30 | ChanneldAwaitingLockin = 1; 31 | ChanneldNormal = 2; 32 | ChanneldShuttingDown = 3; 33 | ClosingdSigexchange = 4; 34 | ClosingdComplete = 5; 35 | AwaitingUnilateral = 6; 36 | FundingSpendSeen = 7; 37 | Onchain = 8; 38 | DualopendOpenInit = 9; 39 | DualopendAwaitingLockin = 10; 40 | ChanneldAwaitingSplice = 11; 41 | } 42 | 43 | enum HtlcState { 44 | SentAddHtlc = 0; 45 | SentAddCommit = 1; 46 | RcvdAddRevocation = 2; 47 | RcvdAddAckCommit = 3; 48 | SentAddAckRevocation = 4; 49 | RcvdAddAckRevocation = 5; 50 | RcvdRemoveHtlc = 6; 51 | RcvdRemoveCommit = 7; 52 | SentRemoveRevocation = 8; 53 | SentRemoveAckCommit = 9; 54 | RcvdRemoveAckRevocation = 10; 55 | RcvdAddHtlc = 11; 56 | RcvdAddCommit = 12; 57 | SentAddRevocation = 13; 58 | SentAddAckCommit = 14; 59 | SentRemoveHtlc = 15; 60 | SentRemoveCommit = 16; 61 | RcvdRemoveRevocation = 17; 62 | RcvdRemoveAckCommit = 18; 63 | SentRemoveAckRevocation = 19; 64 | } 65 | 66 | message ChannelStateChangeCause {} 67 | 68 | message Outpoint { 69 | bytes txid = 1; 70 | uint32 outnum = 2; 71 | } 72 | 73 | message Feerate { 74 | oneof style { 75 | bool slow = 1; 76 | bool normal = 2; 77 | bool urgent = 3; 78 | uint32 perkb = 4; 79 | uint32 perkw = 5; 80 | } 81 | } 82 | 83 | message OutputDesc { 84 | string address = 1; 85 | Amount amount = 2; 86 | } 87 | 88 | message RouteHop { 89 | bytes id = 1; 90 | string scid = 2; 91 | Amount feebase = 3; 92 | uint32 feeprop = 4; 93 | uint32 expirydelta = 5; 94 | } 95 | message Routehint { 96 | repeated RouteHop hops = 1; 97 | } 98 | message RoutehintList { 99 | repeated Routehint hints = 2; 100 | } 101 | 102 | message DecodeRouteHop { 103 | bytes pubkey = 1; 104 | string short_channel_id = 2; 105 | Amount fee_base_msat = 3; 106 | uint32 fee_proportional_millionths = 4; 107 | uint32 cltv_expiry_delta = 5; 108 | } 109 | message DecodeRoutehint { 110 | repeated DecodeRouteHop hops = 1; 111 | } 112 | message DecodeRoutehintList { 113 | repeated DecodeRoutehint hints = 2; 114 | } 115 | 116 | message TlvEntry { 117 | uint64 type = 1; 118 | bytes value = 2; 119 | } 120 | 121 | message TlvStream { 122 | repeated TlvEntry entries = 1; 123 | } 124 | 125 | enum ChannelTypeName { 126 | static_remotekey_even = 0; 127 | anchor_outputs_even = 1; 128 | anchors_zero_fee_htlc_tx_even = 2; 129 | scid_alias_even = 3; 130 | zeroconf_even = 4; 131 | anchors_even = 5; 132 | } 133 | 134 | enum AutocleanSubsystem { 135 | SUCCEEDEDFORWARDS = 0; 136 | FAILEDFORWARDS = 1; 137 | SUCCEEDEDPAYS = 2; 138 | FAILEDPAYS = 3; 139 | PAIDINVOICES = 4; 140 | EXPIREDINVOICES = 5; 141 | } 142 | 143 | enum PluginSubcommand { 144 | START = 0; 145 | STOP = 1; 146 | RESCAN = 2; 147 | STARTDIR = 3; 148 | LIST = 4; 149 | } 150 | -------------------------------------------------------------------------------- /api/cashu/auth.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | gonutsCrypto "github.com/elnosh/gonuts/crypto" 11 | ) 12 | 13 | var ( 14 | ErrInvalidAuthToken = errors.New("invalid auth token") 15 | ErrClearTokenExpired = errors.New("clear token is expired") 16 | ) 17 | 18 | type ProtectedRoute struct { 19 | Method string `json:"method"` 20 | Path string `json:"path"` 21 | } 22 | 23 | type Nut21Info struct { 24 | OpenIdDiscovery string `json:"openid_discovery"` 25 | ClientId string `json:"client_id"` 26 | ProtectedRoutes []ProtectedRoute `json:"protected_endpoints"` 27 | } 28 | 29 | type PostAuthBlindMintRequest struct { 30 | Outputs []BlindedMessage `json:"outputs"` 31 | } 32 | 33 | type AuthProof struct { 34 | Id string `json:"id"` 35 | Secret string `json:"secret"` 36 | C WrappedPublicKey `json:"C" db:"c"` 37 | Amount uint64 `json:"amount" db:"amount"` 38 | } 39 | 40 | func (a AuthProof) Y() (WrappedPublicKey, error) { 41 | // Get Hash to curve of secret 42 | parsedSecret := []byte(a.Secret) 43 | 44 | y, err := gonutsCrypto.HashToCurve(parsedSecret) 45 | 46 | if err != nil { 47 | return WrappedPublicKey{}, fmt.Errorf("crypto.HashToCurve: %+v", err) 48 | } 49 | 50 | return WrappedPublicKey{y}, nil 51 | } 52 | 53 | // creates a normal proof for storage 54 | func (a AuthProof) Proof(y WrappedPublicKey, state ProofState) Proof { 55 | var proof Proof 56 | 57 | proof.Amount = a.Amount 58 | proof.Id = a.Id 59 | proof.Y = y 60 | proof.C = a.C 61 | proof.Secret = a.Secret 62 | proof.SeenAt = time.Now().Unix() 63 | proof.State = state 64 | 65 | return proof 66 | } 67 | 68 | type AuthClams struct { 69 | Sub string `json:"sub"` 70 | ClientId string `json:"client_id"` 71 | Aud *[]string `json:"aud"` 72 | } 73 | 74 | type PostAuthBlindMintResponse struct { 75 | Signatures []BlindSignature `json:"signatures"` 76 | } 77 | type Nut22Info struct { 78 | BatMaxMint uint64 `json:"bat_max_mint"` 79 | ProtectedRoutes []ProtectedRoute `json:"protected_endpoints"` 80 | } 81 | 82 | func ConvertRouteListToProtectedRouteList(list []string) []ProtectedRoute { 83 | 84 | routes := []ProtectedRoute{} 85 | 86 | for _, v := range list { 87 | routes = append(routes, ProtectedRoute{ 88 | Method: "POST", 89 | Path: v, 90 | }, ProtectedRoute{ 91 | Method: "GET", 92 | Path: v, 93 | }, 94 | ) 95 | 96 | } 97 | return routes 98 | } 99 | 100 | func DecodeAuthToken(tokenstr string) (AuthProof, error) { 101 | prefixVersion := tokenstr[:5] 102 | base64Token := tokenstr[5:] 103 | if prefixVersion != "authA" { 104 | return AuthProof{}, ErrInvalidAuthToken 105 | } 106 | 107 | tokenBytes, err := base64.URLEncoding.DecodeString(base64Token) 108 | if err != nil { 109 | tokenBytes, err = base64.RawURLEncoding.DecodeString(base64Token) 110 | if err != nil { 111 | return AuthProof{}, fmt.Errorf("error decoding token: %v", err) 112 | } 113 | } 114 | 115 | var authProof AuthProof 116 | err = json.Unmarshal(tokenBytes, &authProof) 117 | if err != nil { 118 | return AuthProof{}, fmt.Errorf("cbor.Unmarshal: %v", err) 119 | } 120 | 121 | return authProof, nil 122 | } 123 | -------------------------------------------------------------------------------- /internal/mint/bolt11.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | 10 | "github.com/lescuer97/nutmix/api/cashu" 11 | "github.com/lescuer97/nutmix/internal/lightning" 12 | "github.com/lightningnetwork/lnd/invoices" 13 | "github.com/lightningnetwork/lnd/zpay32" 14 | ) 15 | 16 | func CheckMintRequest(mint *Mint, quote cashu.MintRequestDB, invoice *zpay32.Invoice) (cashu.MintRequestDB, error) { 17 | 18 | status, _, err := mint.LightningBackend.CheckReceived(quote, invoice) 19 | if err != nil { 20 | return quote, fmt.Errorf("mint.VerifyLightingPaymentHappened(pool, quote.RequestPaid. %w", err) 21 | } 22 | switch status { 23 | case lightning.SETTLED: 24 | quote.State = cashu.PAID 25 | quote.RequestPaid = true 26 | // case lightning.PENDING: 27 | // quote.State = cashu.PENDING 28 | case lightning.FAILED: 29 | quote.State = cashu.UNPAID 30 | 31 | } 32 | return quote, nil 33 | 34 | } 35 | 36 | func CheckMeltRequest(mint *Mint, quoteId string) (cashu.PostMeltQuoteBolt11Response, error) { 37 | 38 | tx, err := mint.MintDB.GetTx(context.Background()) 39 | if err != nil { 40 | return cashu.PostMeltQuoteBolt11Response{}, fmt.Errorf("m.MintDB.GetTx(ctx). %w", err) 41 | } 42 | 43 | defer func() { 44 | if err != nil { 45 | if rollbackErr := mint.MintDB.Rollback(context.Background(), tx); rollbackErr != nil { 46 | slog.Warn("rollback error", slog.Any("error", rollbackErr)) 47 | } 48 | } 49 | }() 50 | 51 | quote, err := mint.MintDB.GetMeltRequestById(tx, quoteId) 52 | if err != nil { 53 | return quote.GetPostMeltQuoteResponse(), fmt.Errorf("database.GetMintQuoteById(pool, quoteId). %w", err) 54 | } 55 | 56 | if quote.State == cashu.PAID || quote.State == cashu.ISSUED { 57 | return quote.GetPostMeltQuoteResponse(), nil 58 | } 59 | 60 | invoice, err := zpay32.Decode(quote.Request, mint.LightningBackend.GetNetwork()) 61 | if err != nil { 62 | return quote.GetPostMeltQuoteResponse(), fmt.Errorf("zpay32.Decode(quote.Request, mint.LightningBackend.GetNetwork()). %w", err) 63 | } 64 | 65 | status, preimage, fees, err := mint.LightningBackend.CheckPayed(quote.Quote, invoice, quote.CheckingId) 66 | if err != nil { 67 | if errors.Is(err, invoices.ErrInvoiceNotFound) || strings.Contains(err.Error(), "NotFound") { 68 | return quote.GetPostMeltQuoteResponse(), nil 69 | } 70 | return quote.GetPostMeltQuoteResponse(), fmt.Errorf("mint.LightningBackend.CheckPayed(quote.Quote). %w", err) 71 | } 72 | 73 | switch status { 74 | case lightning.SETTLED: 75 | quote.PaymentPreimage = preimage 76 | quote.State = cashu.PAID 77 | quote.FeePaid = fees 78 | quote.RequestPaid = true 79 | 80 | case lightning.PENDING: 81 | quote.State = cashu.PENDING 82 | case lightning.FAILED: 83 | quote.State = cashu.UNPAID 84 | 85 | } 86 | 87 | err = mint.MintDB.AddPreimageMeltRequest(tx, quote.Quote, preimage) 88 | if err != nil { 89 | return quote.GetPostMeltQuoteResponse(), fmt.Errorf("database.AddPaymentPreimageToMeltRequest(pool, preimage, quote.Quote) %w", err) 90 | } 91 | 92 | err = mint.MintDB.Commit(context.Background(), tx) 93 | if err != nil { 94 | return quote.GetPostMeltQuoteResponse(), fmt.Errorf("mint.MintDB.Commit(ctx tx). %w", err) 95 | } 96 | 97 | return quote.GetPostMeltQuoteResponse(), nil 98 | 99 | } 100 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Nutmix 2 | on: 3 | pull_request: 4 | branches: [master, signet, admin_dashboard] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v5 11 | - uses: actions/setup-go@v6 12 | with: 13 | go-version: '1.25.4' 14 | cache: true 15 | 16 | - name: Go Toolchain info 17 | run: | 18 | go version 19 | 20 | - name: Install Just 21 | run: | 22 | sudo apt-get update 23 | sudo apt-get install -y just 24 | 25 | - name: Set up Bun 26 | uses: oven-sh/setup-bun@v1 27 | with: 28 | bun-version: latest 29 | 30 | - name: Install dependencies 31 | run: just install-deps 32 | 33 | - name: Generate code (proto, templ) 34 | run: | 35 | just gen-proto 36 | just gen-templ 37 | just web-install 38 | 39 | - name: Build 40 | run: just build 41 | 42 | build-test: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v5 46 | - uses: actions/setup-go@v6 47 | with: 48 | go-version: '1.25.4' 49 | cache: true 50 | 51 | - name: Go Toolchain info 52 | run: | 53 | go version 54 | 55 | - name: Install Just 56 | run: | 57 | sudo apt-get update 58 | sudo apt-get install -y just 59 | 60 | - name: Set up Bun 61 | uses: oven-sh/setup-bun@v1 62 | with: 63 | bun-version: latest 64 | 65 | - name: Install dependencies 66 | run: just install-deps 67 | 68 | - name: Generate code (proto, templ) 69 | run: | 70 | just gen-proto 71 | just gen-templ 72 | just web-install 73 | 74 | - name: Test build 75 | run: just build 76 | 77 | test: 78 | runs-on: ubuntu-latest 79 | strategy: 80 | fail-fast: false 81 | matrix: 82 | test-dir: 83 | - api/cashu 84 | - cmd/nutmix 85 | - internal/mint 86 | - internal/signer 87 | - internal/database/postgresql 88 | - internal/lightning 89 | - internal/routes/admin 90 | - internal/routes/middleware 91 | - internal/utils 92 | - pkg/crypto 93 | - test/configTest 94 | - test/setupTest 95 | 96 | steps: 97 | - uses: actions/checkout@v5 98 | - uses: actions/setup-go@v6 99 | with: 100 | go-version: '1.25.4' 101 | cache: true 102 | 103 | - name: Go Toolchain info 104 | run: | 105 | go version 106 | 107 | - name: Install Just 108 | run: | 109 | sudo apt-get update 110 | sudo apt-get install -y just 111 | 112 | - name: Set up Bun 113 | uses: oven-sh/setup-bun@v1 114 | with: 115 | bun-version: latest 116 | 117 | - name: Install dependencies 118 | run: just install-deps 119 | 120 | - name: Generate code (proto, templ) 121 | run: | 122 | just gen-proto 123 | just gen-templ 124 | just web-install 125 | just web-build-prod 126 | 127 | - name: Test ${{ matrix.test-dir }} 128 | run: go test -v ./${{ matrix.test-dir }}/... 129 | -------------------------------------------------------------------------------- /internal/routes/admin/static/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} UnsignedNostrEvent 3 | * @property {number} created_at - should be a unix timestamp 4 | * @property {number} kind 5 | * @property {Array[][]} tags 6 | * @property {string} content 7 | */ 8 | /** 9 | * @typedef {Object} SignedNostrEvent 10 | * @property {number} created_at - should be a unix timestamp 11 | * @property {number} kind 12 | * @property {Array[][]} tags 13 | * @property {string} content 14 | * @property {string} id 15 | * @property {string} sig 16 | */ 17 | 18 | let nip07form = document.getElementById("nip07-form"); 19 | // sig nonce sent by the server, in case of success navigate. if an error occurs show an error 20 | nip07form?.addEventListener("submit", (e) => { 21 | e.preventDefault(); 22 | 23 | let formValues = Object.values(e.target).reduce((obj, field) => { 24 | obj[field.name] = field.value; 25 | return obj; 26 | }, {}); 27 | 28 | /** @type {UnsignedNostrEvent}*/ 29 | const eventToSign = { 30 | created_at: Math.floor(Date.now() / 1000), 31 | kind: 27235, 32 | tags: [], 33 | content: formValues.passwordNonce, 34 | }; 35 | 36 | window.nostr 37 | .signEvent(eventToSign) 38 | .then( 39 | ( 40 | /** 41 | @type {SignedNostrEvent} 42 | */ signedEvent 43 | ) => { 44 | const loginRequest = new Request("/admin/login", { 45 | method: "POST", 46 | body: JSON.stringify(signedEvent), 47 | }); 48 | 49 | fetch(loginRequest) 50 | .then((res) => { 51 | if (res.ok) { 52 | window.location.href = "/admin"; 53 | } else { 54 | const targetHeader = res.headers.get("HX-RETARGET"); 55 | 56 | if (window.htmx && targetHeader) { 57 | res 58 | .text() 59 | .then((text) => { 60 | window.htmx.swap(targetHeader, text, { 61 | swapStyle: "innerHTML", 62 | }); 63 | }) 64 | .catch((err) => { 65 | console.log({ errText: err }); 66 | }); 67 | } 68 | } 69 | }) 70 | .catch((err) => { 71 | console.log("Error message"); 72 | console.log({ err }); 73 | }); 74 | } 75 | ) 76 | .catch((err) => { 77 | console.log({ err }); 78 | }); 79 | }); 80 | 81 | // // check for click on button for age of logs 82 | 83 | // /** 84 | // * @type NodeListOf 85 | // * */ 86 | // const buttons = document.querySelectorAlquerySelectorAlll(".time-button"); 87 | 88 | // document.querySelector(".time-select")?.addEventListener("click", (evt) => { 89 | // // turn all time buttons off by removing class 90 | // if (buttons) { 91 | // for (let i = 0; i < buttons.length; i++) { 92 | // const element = buttons[i]; 93 | 94 | // element.classList.remove("selected"); 95 | // } 96 | // } 97 | 98 | // evt.target?.classList.add("selected"); 99 | // window.htmx.trigger(".summary-table", "reload", { time: evt.target?.value }); 100 | // window.htmx.trigger(".log-table", "reload", { time: evt.target?.value }); 101 | // window.htmx.trigger(".mint-melt-table ", "reload", { 102 | // time: evt.target?.value, 103 | // }); 104 | // }); 105 | // 106 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/components.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | // TimeRangeSelector is a dropdown component for selecting preset time ranges 4 | templ TimeRangeSelector(selectedRange string) { 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 25 |
26 |
27 | 28 | Updating charts... 29 |
30 | } 31 | 32 | // QR component 33 | templ QRCode(qrData string) { 34 | QR Code 35 | } 36 | 37 | templ MultiSelect(id string, name string, options []string, selectedValues []string) { 38 | 48 | } 49 | 50 | templ ExpansionPanel(title string, description string, summaryExtra templ.Component) { 51 |
52 | 53 |
54 |
55 |

{ title }

56 | if description != "" { 57 |

{ description }

58 | } 59 |
60 |
61 | if summaryExtra != nil { 62 |
63 | @summaryExtra 64 |
65 | } 66 |
67 | 79 | 80 | 81 |
82 |
83 |
84 |
85 |
86 |
87 | { children... } 88 |
89 |
90 |
91 | } 92 | 93 | func stringOrEmpty(s *string) string { 94 | if s == nil { 95 | return "" 96 | } 97 | return *s 98 | } 99 | 100 | func isSelected(value string, selectedValues []string) bool { 101 | for _, selectedValue := range selectedValues { 102 | if selectedValue == value { 103 | return true 104 | } 105 | } 106 | return false 107 | } 108 | -------------------------------------------------------------------------------- /internal/lightning/invoice.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import ( 4 | "crypto/rand" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/btcsuite/btcd/chaincfg" 10 | "github.com/decred/dcrd/dcrec/secp256k1/v4" 11 | "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" 12 | "github.com/lightningnetwork/lnd/lnrpc" 13 | "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" 14 | "github.com/lightningnetwork/lnd/lntypes" 15 | "github.com/lightningnetwork/lnd/zpay32" 16 | ) 17 | 18 | // mockMppPaymentHashAndPreimage returns the payment hash and preimage to use for an 19 | // MPP invoice. 20 | func mockMppPaymentHashAndPreimage(d *invoicesrpc.AddInvoiceData) (*lntypes.Preimage, 21 | lntypes.Hash, error) { 22 | 23 | var ( 24 | paymentPreimage *lntypes.Preimage 25 | paymentHash lntypes.Hash 26 | ) 27 | 28 | switch { 29 | 30 | // Only either preimage or hash can be set. 31 | case d.Preimage != nil && d.Hash != nil: 32 | return nil, lntypes.Hash{}, 33 | errors.New("preimage and hash both set") 34 | 35 | // If no hash or preimage is given, generate a random preimage. 36 | case d.Preimage == nil && d.Hash == nil: 37 | paymentPreimage = &lntypes.Preimage{} 38 | if _, err := rand.Read(paymentPreimage[:]); err != nil { 39 | return nil, lntypes.Hash{}, err 40 | } 41 | paymentHash = paymentPreimage.Hash() 42 | 43 | // If just a hash is given, we create a hold invoice by setting the 44 | // preimage to unknown. 45 | case d.Preimage == nil && d.Hash != nil: 46 | paymentHash = *d.Hash 47 | 48 | // A specific preimage was supplied. Use that for the invoice. 49 | case d.Preimage != nil && d.Hash == nil: 50 | preimage := *d.Preimage 51 | paymentPreimage = &preimage 52 | paymentHash = d.Preimage.Hash() 53 | } 54 | 55 | return paymentPreimage, paymentHash, nil 56 | } 57 | 58 | func CreateMockInvoice(amountSats uint64, description string, network chaincfg.Params, expiry int64) (string, error) { 59 | milsats, err := lnrpc.UnmarshallAmt(int64(amountSats), 0) 60 | if err != nil { 61 | return "", fmt.Errorf("UnmarshallAmt: %w", err) 62 | } 63 | 64 | invoiceData := invoicesrpc.AddInvoiceData{ 65 | Memo: description, 66 | Value: milsats, 67 | Preimage: nil, 68 | Expiry: expiry, 69 | Private: false, 70 | Hash: nil, 71 | } 72 | 73 | _, paymentHash, err := mockMppPaymentHashAndPreimage(&invoiceData) 74 | if err != nil { 75 | return "", fmt.Errorf("mockMppPaymentHashAndPreimage: %w", err) 76 | } 77 | 78 | var options []func(*zpay32.Invoice) 79 | 80 | options = append(options, zpay32.Description(description)) 81 | options = append(options, zpay32.Amount(milsats)) 82 | options = append(options, zpay32.CLTVExpiry(64000)) 83 | 84 | // Generate and set a random payment address for this invoice. If the 85 | // sender understands payment addresses, this can be used to avoid 86 | // intermediaries probing the receiver. 87 | var paymentAddr [32]byte 88 | if _, err := rand.Read(paymentAddr[:]); err != nil { 89 | return "", fmt.Errorf("paymentAddres Creation: %w", err) 90 | } 91 | options = append(options, zpay32.PaymentAddr(paymentAddr)) 92 | 93 | creationTime := time.Now() 94 | payReq, err := zpay32.NewInvoice(&network, paymentHash, creationTime, options...) 95 | 96 | if err != nil { 97 | return "", err 98 | 99 | } 100 | 101 | payReqString, err := payReq.Encode(zpay32.MessageSigner{ 102 | SignCompact: func(msg []byte) ([]byte, error) { 103 | key, err := secp256k1.GeneratePrivateKey() 104 | 105 | if err != nil { 106 | return make([]byte, 0), fmt.Errorf("GeneratePrivateKey: %w ", err) 107 | } 108 | 109 | return ecdsa.SignCompact(key, msg, true), nil 110 | }, 111 | }) 112 | 113 | if err != nil { 114 | return "", fmt.Errorf("SignMessage: %w", err) 115 | } 116 | 117 | return payReqString, nil 118 | } 119 | -------------------------------------------------------------------------------- /internal/routes/admin/templates/keysets.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import "strconv" 4 | import "time" 5 | 6 | type KeysetData struct { 7 | Id string 8 | Active bool 9 | Unit string 10 | Fees uint 11 | CreatedAt int64 12 | Version uint64 13 | ExpireLimit *time.Time 14 | } 15 | 16 | templ KeysetsPage() { 17 | @Layout("keysets") { 18 |
19 |
20 |

Rotate Keysets

21 |
28 | 36 | 40 | 44 |
45 | 48 |
49 |
50 |
51 |
52 |
58 |
59 | } 60 | } 61 | 62 | templ KeysetsList(keysetMap map[string][]KeysetData, orderedUnits []string) { 63 | for _, unit := range orderedUnits { 64 | {{ keysets := keysetMap[unit] }} 65 |
66 |

67 | { unit } 68 |

69 |
70 | for _, keyset := range keysets { 71 | @keysetCard(keyset) 72 | } 73 |
74 |
75 | } 76 | } 77 | 78 | templ keysetCard(keyset KeysetData) { 79 | {{ inputFeeStr := strconv.FormatUint(uint64(keyset.Fees), 10) }} 80 | {{ versionStr := strconv.FormatUint(keyset.Version, 10) }} 81 |
82 |
83 | Id: { keyset.Id } 84 | if keyset.Active { 85 | Active 86 | } else { 87 | Inactive 88 | } 89 |
90 |
91 |
92 |
93 | Unit 94 | { keyset.Unit } 95 |
96 | if keyset.Unit != "auth" { 97 |
98 | Fees (PPK) 99 | { inputFeeStr } 100 |
101 | } 102 |
103 | Version 104 | { versionStr } 105 |
106 |
107 |
108 |
109 | } 110 | -------------------------------------------------------------------------------- /internal/signer/local_signer/local_signer_test.go: -------------------------------------------------------------------------------- 1 | package localsigner 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/lescuer97/nutmix/api/cashu" 8 | mockdb "github.com/lescuer97/nutmix/internal/database/mock_db" 9 | ) 10 | 11 | const MintPrivateKey string = "0000000000000000000000000000000000000000000000000000000000000001" 12 | 13 | func TestRotateUnexistingSeedUnit(t *testing.T) { 14 | db := mockdb.MockDB{} 15 | t.Setenv("MINT_PRIVATE_KEY", MintPrivateKey) 16 | localsigner, err := SetupLocalSigner(&db) 17 | if err != nil { 18 | t.Fatalf("SetupLocalSigner(&db) %+v", err) 19 | } 20 | _, err = localsigner.getSignerPrivateKey() 21 | if err != nil { 22 | t.Fatalf("getSignerPrivateKey failed: %v", err) 23 | } 24 | 25 | err = localsigner.RotateKeyset(cashu.Msat, uint(100), 240) 26 | if err != nil { 27 | t.Fatalf("localsigner.RotateKeyset(cashu.Msat, uint(100)) %+v", err) 28 | } 29 | 30 | err = localsigner.RotateKeyset(cashu.Sat, uint(100), 240) 31 | if err != nil { 32 | t.Fatalf("localsigner.RotateKeyset(cashu.Sat, uint(100)) %+v", err) 33 | } 34 | 35 | keys, err := localsigner.GetKeysets() 36 | if err != nil { 37 | t.Fatalf("localsigner.GetKeys() %+v", err) 38 | 39 | } 40 | if len(keys.Keysets) != 3 { 41 | t.Errorf("Version should be 3. it's %v", len(keys.Keysets)) 42 | } 43 | ctx := context.Background() 44 | tx, err := localsigner.db.GetTx(ctx) 45 | if err != nil { 46 | t.Fatalf("localsigner.db.GetTx(ctx) %+v", err) 47 | } 48 | 49 | msatSeeds, err := db.GetSeedsByUnit(tx, cashu.Msat) 50 | if err != nil { 51 | t.Fatalf("db.GetSeedsByUnit(cashu.Msat) %+v", err) 52 | } 53 | 54 | if msatSeeds[0].Version != 1 { 55 | t.Error("Version should be 1") 56 | } 57 | if msatSeeds[0].InputFeePpk != uint(100) { 58 | t.Errorf("Input fee should be 100. its %v", msatSeeds[0].InputFeePpk) 59 | } 60 | 61 | satSeeds, err := db.GetSeedsByUnit(tx, cashu.Sat) 62 | if err != nil { 63 | t.Fatalf("db.GetSeedsByUnit(cashu.Sat) %+v", err) 64 | } 65 | 66 | if satSeeds[1].Version != 2 { 67 | t.Error("Version should be 2") 68 | } 69 | if satSeeds[1].InputFeePpk != uint(100) { 70 | t.Errorf("Input fee should be 100. its %v", msatSeeds[0].InputFeePpk) 71 | } 72 | if len(satSeeds) != 2 { 73 | t.Errorf("Version should be 2 seeds. it's %v", len(keys.Keysets)) 74 | } 75 | } 76 | 77 | func TestCreateNewSeed(t *testing.T) { 78 | db := mockdb.MockDB{} 79 | t.Setenv("MINT_PRIVATE_KEY", MintPrivateKey) 80 | localsigner, err := SetupLocalSigner(&db) 81 | if err != nil { 82 | t.Fatalf("SetupLocalSigner(&db) %+v", err) 83 | } 84 | 85 | keys, err := localsigner.GetActiveKeys() 86 | if err != nil { 87 | t.Fatalf("localsigner.GetActiveKeys() %+v", err) 88 | } 89 | 90 | if keys.Keysets[0].Id != "00bfa73302d12ffd" { 91 | t.Errorf("seed id incorrect. %v", keys.Keysets[1].Id) 92 | } 93 | } 94 | func TestRotateAuthSeedUnit(t *testing.T) { 95 | db := mockdb.MockDB{} 96 | t.Setenv("MINT_PRIVATE_KEY", MintPrivateKey) 97 | localsigner, err := SetupLocalSigner(&db) 98 | if err != nil { 99 | t.Fatalf("SetupLocalSigner(&db) %+v", err) 100 | } 101 | _, err = localsigner.getSignerPrivateKey() 102 | if err != nil { 103 | t.Fatalf("getSignerPrivateKey failed: %v", err) 104 | } 105 | 106 | err = localsigner.RotateKeyset(cashu.AUTH, uint(100), 240) 107 | if err != nil { 108 | t.Fatalf("localsigner.RotateKeyset(cashu.Msat, uint(100)) %+v", err) 109 | } 110 | 111 | keys, err := localsigner.GetAuthActiveKeys() 112 | if err != nil { 113 | t.Fatalf("localsigner.GetKeys() %+v", err) 114 | 115 | } 116 | if len(keys.Keysets) != 1 { 117 | 118 | t.Errorf("There should only be one keyset for auth. there is: %v", len(keys.Keysets)) 119 | } 120 | 121 | if keys.Keysets[0].Unit != cashu.AUTH.String() { 122 | t.Errorf("Should be Auth key: it is %v", keys.Keysets[0].Unit) 123 | } 124 | 125 | _, ok := keys.Keysets[0].Keys[1] 126 | if !ok { 127 | t.Errorf("We should have a keysey of value 1. %+v", keys.Keysets[0]) 128 | } 129 | // if keys.Keysets[1].Keys[] == 1 { 130 | // t.Errorf("Should be Auth key %v",keys.Keysets[1].Unit) 131 | // } 132 | } 133 | -------------------------------------------------------------------------------- /internal/mint/auth.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "time" 9 | 10 | "github.com/coreos/go-oidc/v3/oidc" 11 | "github.com/jackc/pgx/v5" 12 | "github.com/lescuer97/nutmix/api/cashu" 13 | ) 14 | 15 | func (m *Mint) verifyClams(clams cashu.AuthClams) error { 16 | ctx := context.Background() 17 | 18 | if clams.ClientId != m.Config.MINT_AUTH_OICD_CLIENT_ID { 19 | return cashu.ErrInvalidAuthToken 20 | } 21 | tx, err := m.MintDB.GetTx(ctx) 22 | if err != nil { 23 | return fmt.Errorf("m.MintDB.GetTx(ctx). %w", err) 24 | } 25 | defer func() { 26 | if err != nil { 27 | if rollbackErr := m.MintDB.Rollback(ctx, tx); rollbackErr != nil { 28 | slog.Warn("rollback error", slog.Any("error", rollbackErr)) 29 | } 30 | } 31 | }() 32 | 33 | now := time.Now() 34 | authUser, err := m.MintDB.GetAuthUser(tx, clams.Sub) 35 | if err != nil { 36 | // if the user doesn't exist we create it 37 | if !errors.Is(err, pgx.ErrNoRows) { 38 | return fmt.Errorf("m.MintDB.GetAuthUser(tx, clams.Sub). %w", err) 39 | } 40 | authUser.Sub = clams.Sub 41 | authUser.LastLoggedIn = uint64(now.Unix()) 42 | err = m.MintDB.MakeAuthUser(tx, authUser) 43 | if err != nil { 44 | return fmt.Errorf("m.MintDB.MakeAuthUser(tx, authUser). %w", err) 45 | } 46 | } 47 | 48 | authUser.LastLoggedIn = uint64(now.Unix()) 49 | err = m.MintDB.UpdateLastLoggedIn(tx, authUser.Sub, authUser.LastLoggedIn) 50 | if err != nil { 51 | return fmt.Errorf("m.MintDB.UpdateLastLoggedIn(tx,authUser.Sub, authUser.LastLoggedIn). %w", err) 52 | } 53 | 54 | err = m.MintDB.Commit(ctx, tx) 55 | if err != nil { 56 | return fmt.Errorf("mint.MintDB.Commit(ctx tx). %w", err) 57 | } 58 | 59 | return nil 60 | 61 | } 62 | 63 | func (m *Mint) VerifyAuthClearToken(token string) error { 64 | verifier := m.OICDClient.Verifier(&oidc.Config{ClientID: m.Config.MINT_AUTH_OICD_CLIENT_ID, Now: time.Now, SkipClientIDCheck: false}) 65 | 66 | ctx := context.Background() 67 | idToken, err := verifier.Verify(ctx, token) 68 | if err != nil { 69 | return fmt.Errorf("verifier.Verify(ctx,token ). %w", err) 70 | } 71 | now := time.Now() 72 | if now.Unix() >= idToken.Expiry.Unix() { 73 | return cashu.ErrClearTokenExpired 74 | } 75 | clams := cashu.AuthClams{} 76 | err = idToken.Claims(&clams) 77 | if err != nil { 78 | return fmt.Errorf("idToken.Claims(&clams). %w", err) 79 | } 80 | err = m.verifyClams(clams) 81 | if err != nil { 82 | return fmt.Errorf("m.verifyClams(clams). %w", err) 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (m *Mint) VerifyAuthBlindToken(authProof cashu.AuthProof) error { 89 | ctx := context.Background() 90 | 91 | y, err := authProof.Y() 92 | if err != nil { 93 | return fmt.Errorf("authProof.Y(). %w", err) 94 | } 95 | 96 | tx, err := m.MintDB.GetTx(ctx) 97 | if err != nil { 98 | return fmt.Errorf("m.MintDB.GetTx(ctx). %w", err) 99 | } 100 | defer func() { 101 | if err != nil { 102 | if rollbackErr := m.MintDB.Rollback(ctx, tx); rollbackErr != nil { 103 | slog.Warn("rollback error", slog.Any("error", rollbackErr)) 104 | } 105 | } 106 | }() 107 | 108 | proofsList, err := m.MintDB.GetProofsFromSecretCurve(tx, []cashu.WrappedPublicKey{y}) 109 | if err != nil { 110 | return fmt.Errorf("m.MintDB.GetProofsFromSecretCurve(tx, []string{y} ). %w", err) 111 | } 112 | if len(proofsList) > 0 { 113 | return fmt.Errorf("authProof already used. %w", err) 114 | } 115 | 116 | proof := authProof.Proof(y, cashu.PROOF_PENDING) 117 | proofArray := cashu.Proofs{proof} 118 | err = m.MintDB.SaveProof(tx, proofArray) 119 | if err != nil { 120 | return fmt.Errorf("m.MintDB.SaveProof(tx, proofArray). %w", err) 121 | } 122 | 123 | err = m.Signer.VerifyProofs(proofArray) 124 | if err != nil { 125 | return fmt.Errorf("m.Signer.VerifyProofs(proofArray, nil). %w", err) 126 | } 127 | 128 | proofArray.SetProofsState(cashu.PROOF_SPENT) 129 | 130 | err = m.MintDB.SetProofsState(tx, proofArray, cashu.PROOF_SPENT) 131 | if err != nil { 132 | return fmt.Errorf("m.MintDB.GetProofsFromSecretCurve(tx, []string{y} ). %w", err) 133 | } 134 | 135 | err = m.MintDB.Commit(ctx, tx) 136 | if err != nil { 137 | return fmt.Errorf("mint.MintDB.Commit(ctx tx). %w", err) 138 | } 139 | 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /internal/routes/admin/keysets.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "strconv" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/lescuer97/nutmix/api/cashu" 12 | m "github.com/lescuer97/nutmix/internal/mint" 13 | "github.com/lescuer97/nutmix/internal/routes/admin/templates" 14 | "github.com/lescuer97/nutmix/internal/utils" 15 | ) 16 | 17 | var ErrUnitNotCorrect = errors.New("unit not correct") 18 | var ErrNoExpiryTime = errors.New("no expiry time provided") 19 | 20 | func KeysetsPage(mint *m.Mint) gin.HandlerFunc { 21 | 22 | return func(c *gin.Context) { 23 | ctx := context.Background() 24 | err := templates.KeysetsPage().Render(ctx, c.Writer) 25 | 26 | if err != nil { 27 | _ = c.Error(fmt.Errorf("templates.KeysetsPage().Render(ctx, c.Writer). %w", err)) 28 | // c.HTML(400,"", nil) 29 | return 30 | } 31 | 32 | } 33 | } 34 | func KeysetsLayoutPage(adminHandler *adminHandler) gin.HandlerFunc { 35 | return func(c *gin.Context) { 36 | keysetMap, orderedUnits, err := adminHandler.getKeysets(nil) 37 | if err != nil { 38 | _ = c.Error(fmt.Errorf("adminHandler.getKeysets(nil). %w", err)) 39 | return 40 | } 41 | ctx := context.Background() 42 | err = templates.KeysetsList(keysetMap, orderedUnits).Render(ctx, c.Writer) 43 | 44 | if err != nil { 45 | _ = c.Error(fmt.Errorf("templates.KeysetsList(keysetArr.Keysets).Render(ctx, c.Writer). %w", err)) 46 | return 47 | } 48 | } 49 | } 50 | 51 | type RotateRequest struct { 52 | Fee uint 53 | Unit cashu.Unit 54 | ExpireLimitHours uint 55 | } 56 | 57 | func RotateSatsSeed(adminHandler *adminHandler) gin.HandlerFunc { 58 | return func(c *gin.Context) { 59 | var rotateRequest RotateRequest 60 | if c.ContentType() == gin.MIMEJSON { 61 | err := c.BindJSON(rotateRequest) 62 | if err != nil { 63 | c.JSON(400, nil) 64 | return 65 | } 66 | } else { 67 | // get Inputed fee 68 | feeString := c.Request.PostFormValue("FEE") 69 | 70 | unitStr := c.Request.PostFormValue("UNIT") 71 | 72 | if unitStr == "" { 73 | _ = c.Error(ErrUnitNotCorrect) 74 | return 75 | } 76 | 77 | expireLimitStr := c.Request.PostFormValue("EXPIRE_LIMIT") 78 | if expireLimitStr == "" { 79 | _ = c.Error(ErrNoExpiryTime) 80 | return 81 | } 82 | 83 | unit, err := cashu.UnitFromString(unitStr) 84 | if err != nil { 85 | _ = c.Error(fmt.Errorf("cashu.UnitFromString(unitStr). %w. %w", err, ErrUnitNotCorrect)) 86 | return 87 | } 88 | rotateRequest.Unit = unit 89 | 90 | newSeedFee, err := strconv.ParseUint(feeString, 10, 64) 91 | if err != nil { 92 | slog.Error( 93 | "Err: There was a problem rotating the key", 94 | slog.String(utils.LogExtraInfo, err.Error())) 95 | 96 | err := RenderError(c, "Fee was not an integer") 97 | if err != nil { 98 | slog.Error("RenderError", slog.Any("error", err)) 99 | } 100 | return 101 | } 102 | rotateRequest.Fee = uint(newSeedFee) 103 | 104 | expiryLimit, err := strconv.ParseUint(expireLimitStr, 10, 64) 105 | if err != nil { 106 | slog.Error( 107 | "Err: There was a problem rotating the key", 108 | slog.String(utils.LogExtraInfo, err.Error())) 109 | 110 | err := RenderError(c, "Expire limit is not an integer") 111 | if err != nil { 112 | slog.Error("RenderError", slog.Any("error", err)) 113 | } 114 | return 115 | } 116 | rotateRequest.ExpireLimitHours = uint(expiryLimit) 117 | } 118 | 119 | err := adminHandler.rotateKeyset(rotateRequest.Unit, rotateRequest.Fee, rotateRequest.ExpireLimitHours) 120 | if err != nil { 121 | slog.Error( 122 | "mint.Signer.RotateKeyset(cashu.Sat, rotateRequest.Fee)", 123 | slog.String(utils.LogExtraInfo, err.Error())) 124 | 125 | err := RenderError(c, "There was an error getting the seeds") 126 | if err != nil { 127 | slog.Error("RenderError", slog.Any("error", err)) 128 | } 129 | return 130 | } 131 | 132 | if c.ContentType() == gin.MIMEJSON { 133 | c.JSON(200, nil) 134 | } else { 135 | 136 | c.Header("HX-Trigger", "recharge-keyset") 137 | err := RenderSuccess(c, "Key succesfully rotated") 138 | if err != nil { 139 | slog.Error("RenderSuccess", slog.Any("error", err)) 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/gen/signer.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package proto_signer; 4 | option go_package = "nutmix_remote_signer/proto_signer"; 5 | 6 | service SignerService { 7 | rpc BlindSign(BlindedMessages) returns (BlindSignResponse); 8 | rpc VerifyProofs(Proofs) returns (BooleanResponse); 9 | // returns all the keysets for the mint 10 | rpc Keysets(EmptyRequest) returns (KeysResponse); 11 | // rotates the keysets 12 | rpc RotateKeyset(RotationRequest) returns (KeyRotationResponse); 13 | } 14 | 15 | enum Operation { 16 | OPERATION_UNSPECIFIED = 0; 17 | OPERATION_MINT = 1; 18 | OPERATION_MELT = 2; 19 | OPERATION_SWAP = 3; 20 | } 21 | 22 | message BlindSignResponse { 23 | Error error = 1; 24 | BlindSignatures sigs = 2; 25 | } 26 | 27 | message BlindedMessages { 28 | repeated BlindedMessage blinded_messages = 1; 29 | Operation operation = 2; 30 | string correlation_id = 3; 31 | } 32 | 33 | // Represents a blinded message 34 | message BlindedMessage { 35 | uint64 amount = 1; 36 | bytes keyset_id = 2; 37 | bytes blinded_secret = 3; 38 | } 39 | 40 | message BooleanResponse { 41 | Error error = 1; 42 | bool success = 2; 43 | } 44 | 45 | message KeyRotationResponse { 46 | Error error = 1; 47 | KeySet keyset = 2; 48 | } 49 | 50 | message KeysResponse { 51 | Error error = 1; 52 | SignatoryKeysets keysets = 2; 53 | } 54 | 55 | message SignatoryKeysets { 56 | bytes pubkey = 1; 57 | repeated KeySet keysets = 2; 58 | } 59 | 60 | message KeySet { 61 | bytes id = 1; 62 | CurrencyUnit unit = 2; 63 | bool active = 3; 64 | uint64 input_fee_ppk = 4; 65 | Keys keys = 5; 66 | uint64 version = 6; 67 | optional uint64 final_expiry = 7; 68 | } 69 | 70 | message Keys { 71 | map keys = 1; 72 | } 73 | 74 | message RotationRequest { 75 | CurrencyUnit unit = 1; 76 | uint64 input_fee_ppk = 2; 77 | repeated uint64 amounts = 3; 78 | // unix timestamp for expiration 79 | optional uint64 final_expiry = 4; 80 | } 81 | 82 | enum CurrencyUnitType { 83 | CURRENCY_UNIT_TYPE_UNSPECIFIED = 0; 84 | CURRENCY_UNIT_TYPE_SAT = 1; 85 | CURRENCY_UNIT_TYPE_MSAT = 2; 86 | CURRENCY_UNIT_TYPE_USD = 3; 87 | CURRENCY_UNIT_TYPE_EUR = 4; 88 | CURRENCY_UNIT_TYPE_AUTH = 5; 89 | } 90 | 91 | message CurrencyUnit { 92 | oneof currency_unit { 93 | CurrencyUnitType unit = 1; 94 | string custom_unit = 2; 95 | } 96 | } 97 | 98 | message Proofs { 99 | repeated Proof proof = 1; 100 | Operation operation = 3; 101 | string correlation_id = 4; 102 | } 103 | 104 | message Proof { 105 | uint64 amount = 1; 106 | bytes keyset_id = 2; 107 | bytes secret = 3; 108 | bytes c = 4; 109 | } 110 | 111 | message ProofDLEQ { 112 | bytes e = 1; 113 | bytes s = 2; 114 | bytes r = 3; 115 | } 116 | 117 | message SigningResponse { 118 | Error error = 1; 119 | BlindSignatures blind_signatures = 2; 120 | } 121 | message BlindSignatures { 122 | repeated BlindSignature blind_signatures = 1; 123 | } 124 | 125 | message BlindSignature { 126 | uint64 amount = 1; 127 | bytes keyset_id = 2; 128 | bytes blinded_secret = 3; 129 | optional BlindSignatureDLEQ dleq = 4; 130 | } 131 | 132 | message BlindSignatureDLEQ { 133 | bytes e = 1; 134 | bytes s = 2; 135 | } 136 | 137 | // Witness type 138 | message Witness { 139 | oneof witness_type { 140 | P2PKWitness p2pk_witness = 1; 141 | HTLCWitness htlc_witness = 2; 142 | } 143 | } 144 | 145 | // P2PKWitness type 146 | message P2PKWitness { 147 | // List of signatures 148 | repeated string signatures = 1; 149 | } 150 | 151 | // HTLCWitness type 152 | message HTLCWitness { 153 | // Preimage 154 | string preimage = 1; 155 | // List of signatures 156 | repeated string signatures = 2; 157 | } 158 | 159 | enum ErrorCode { 160 | ERROR_CODE_UNSPECIFIED = 0; 161 | ERROR_CODE_AMOUNT_OUTSIDE_LIMIT = 1; 162 | ERROR_CODE_DUPLICATE_INPUTS_PROVIDED = 2; 163 | ERROR_CODE_DUPLICATE_OUTPUTS_PROVIDED = 3; 164 | ERROR_CODE_KEYSET_NOT_KNOWN = 4; 165 | ERROR_CODE_KEYSET_INACTIVE = 5; 166 | ERROR_CODE_MINTING_DISABLED = 6; 167 | ERROR_CODE_COULD_NOT_ROTATE_KEYSET = 7; 168 | ERROR_CODE_INVALID_PROOF = 8; 169 | ERROR_CODE_INVALID_BLIND_MESSAGE = 9; 170 | ERROR_CODE_UNIT_NOT_SUPPORTED = 10; 171 | } 172 | 173 | message Error { 174 | ErrorCode code = 1; 175 | string detail = 2; 176 | } 177 | 178 | message EmptyRequest {} 179 | -------------------------------------------------------------------------------- /internal/mint/websocket.go: -------------------------------------------------------------------------------- 1 | package mint 2 | 3 | import ( 4 | "encoding/json" 5 | "slices" 6 | "sync" 7 | 8 | "github.com/gorilla/websocket" 9 | "github.com/lescuer97/nutmix/api/cashu" 10 | ) 11 | 12 | type ProofWatchChannel struct { 13 | Channel chan cashu.Proof 14 | SubId string 15 | } 16 | 17 | type MintQuoteChannel struct { 18 | Channel chan cashu.MintRequestDB 19 | SubId string 20 | } 21 | 22 | type MeltQuoteChannel struct { 23 | Channel chan cashu.MeltRequestDB 24 | SubId string 25 | } 26 | 27 | type Observer struct { 28 | sync.Mutex 29 | // the string is the filters from the websockets 30 | Proofs map[string][]ProofWatchChannel 31 | MintQuote map[string][]MintQuoteChannel 32 | MeltQuote map[string][]MeltQuoteChannel 33 | } 34 | 35 | func (o *Observer) AddProofWatch(y string, proofChan ProofWatchChannel) { 36 | o.Lock() 37 | defer o.Unlock() 38 | val, exists := o.Proofs[y] 39 | 40 | if exists { 41 | val = append(val, proofChan) 42 | o.Proofs[y] = val 43 | } else { 44 | o.Proofs[y] = []ProofWatchChannel{proofChan} 45 | } 46 | } 47 | func (o *Observer) AddMintWatch(quote string, mintChan MintQuoteChannel) { 48 | o.Lock() 49 | defer o.Unlock() 50 | val, exists := o.MintQuote[quote] 51 | 52 | if exists { 53 | val = append(val, mintChan) 54 | o.MintQuote[quote] = val 55 | } else { 56 | o.MintQuote[quote] = []MintQuoteChannel{mintChan} 57 | } 58 | } 59 | func (o *Observer) AddMeltWatch(quote string, meltChan MeltQuoteChannel) { 60 | o.Lock() 61 | defer o.Unlock() 62 | val, exists := o.MeltQuote[quote] 63 | 64 | if exists { 65 | val = append(val, meltChan) 66 | o.MeltQuote[quote] = val 67 | } else { 68 | o.MeltQuote[quote] = []MeltQuoteChannel{meltChan} 69 | } 70 | } 71 | 72 | func (o *Observer) RemoveWatch(subId string) { 73 | o.Lock() 74 | proofChans := []chan cashu.Proof{} 75 | mintRequestChans := []chan cashu.MintRequestDB{} 76 | meltRequestChans := []chan cashu.MeltRequestDB{} 77 | for key, proofWatchArray := range o.Proofs { 78 | for i, proofWatch := range proofWatchArray { 79 | if proofWatch.SubId == subId { 80 | newArray := slices.Delete(proofWatchArray, i, i+1) 81 | o.Proofs[key] = newArray 82 | proofChans = append(proofChans, proofWatch.Channel) 83 | } 84 | } 85 | } 86 | for key, mintWatchArray := range o.MintQuote { 87 | for i, mintWatch := range mintWatchArray { 88 | if mintWatch.SubId == subId { 89 | newArray := slices.Delete(mintWatchArray, i, i+1) 90 | o.MintQuote[key] = newArray 91 | mintRequestChans = append(mintRequestChans, mintWatch.Channel) 92 | } 93 | } 94 | } 95 | for key, meltWatchArray := range o.MeltQuote { 96 | for i, meltWatch := range meltWatchArray { 97 | if meltWatch.SubId == subId { 98 | newArray := slices.Delete(meltWatchArray, i, i+1) 99 | o.MeltQuote[key] = newArray 100 | meltRequestChans = append(meltRequestChans, meltWatch.Channel) 101 | } 102 | } 103 | } 104 | o.Unlock() 105 | for i := range proofChans { 106 | close(proofChans[i]) 107 | } 108 | for i := range mintRequestChans { 109 | close(mintRequestChans[i]) 110 | } 111 | for i := range meltRequestChans { 112 | close(meltRequestChans[i]) 113 | } 114 | } 115 | 116 | func (o *Observer) SendProofsEvent(proofs cashu.Proofs) { 117 | o.Lock() 118 | defer o.Unlock() 119 | 120 | for _, proof := range proofs { 121 | watchArray, exists := o.Proofs[proof.Y.ToHex()] 122 | if exists { 123 | for _, v := range watchArray { 124 | v.Channel <- proof 125 | } 126 | } 127 | } 128 | } 129 | 130 | func (o *Observer) SendMeltEvent(melt cashu.MeltRequestDB) { 131 | o.Lock() 132 | watchArray, exists := o.MeltQuote[melt.Quote] 133 | o.Unlock() 134 | if exists { 135 | for _, v := range watchArray { 136 | v.Channel <- melt 137 | } 138 | } 139 | } 140 | 141 | func (o *Observer) SendMintEvent(mint cashu.MintRequestDB) { 142 | o.Lock() 143 | watchArray, exists := o.MintQuote[mint.Quote] 144 | o.Unlock() 145 | if exists { 146 | for _, v := range watchArray { 147 | v.Channel <- mint 148 | } 149 | } 150 | } 151 | 152 | func SendJson(conn *websocket.Conn, content any) error { 153 | contentToSend, err := json.Marshal(content) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | err = conn.WriteMessage(websocket.TextMessage, contentToSend) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /internal/routes/admin/static/settings.css: -------------------------------------------------------------------------------- 1 | /* Settings form styles */ 2 | 3 | .form-group { 4 | display: flex; 5 | flex-direction: column; 6 | gap: var(--space-6); 7 | } 8 | 9 | .form-section { 10 | display: grid; 11 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 12 | gap: var(--space-4); 13 | } 14 | 15 | /* Labels */ 16 | .settings-input, 17 | .settings-input-checkbox { 18 | display: flex; 19 | flex-direction: column; 20 | gap: var(--space-2); 21 | color: var(--text-secondary); 22 | font-size: var(--text-sm); 23 | font-weight: var(--font-medium); 24 | } 25 | 26 | .settings-input-checkbox { 27 | flex-direction: row; 28 | align-items: center; 29 | justify-content: space-between; 30 | cursor: pointer; 31 | user-select: none; 32 | background: var(--bg-input); 33 | padding: var(--space-3); 34 | border-radius: var(--radius-md); 35 | border: 1px solid var(--border-primary); 36 | transition: border-color var(--transition-fast); 37 | } 38 | 39 | .settings-input-checkbox:hover { 40 | border-color: var(--border-secondary); 41 | } 42 | 43 | /* Inputs & Textarea */ 44 | .settings-input input, 45 | .settings-input textarea, 46 | .settings-input select { 47 | background: var(--bg-input); 48 | border: 1px solid var(--border-primary); 49 | border-radius: var(--radius-md); 50 | padding: var(--space-2) var(--space-3); 51 | color: var(--text-primary); 52 | font-family: inherit; 53 | font-size: var(--text-base); 54 | transition: all var(--transition-fast); 55 | width: 100%; 56 | } 57 | 58 | .settings-input textarea { 59 | min-height: 100px; 60 | resize: vertical; 61 | } 62 | 63 | .settings-input input:focus, 64 | .settings-input textarea:focus, 65 | .settings-input select:focus { 66 | outline: none; 67 | border-color: var(--accent-cyan); 68 | box-shadow: 0 0 0 1px var(--accent-cyan); 69 | } 70 | 71 | .settings-input input:hover, 72 | .settings-input textarea:hover, 73 | .settings-input select:hover { 74 | border-color: var(--border-secondary); 75 | } 76 | 77 | /* Checkbox specific styling */ 78 | .settings-input-checkbox input[type="checkbox"] { 79 | appearance: none; 80 | -webkit-appearance: none; 81 | width: 20px; 82 | height: 20px; 83 | border: 2px solid var(--text-secondary); 84 | border-radius: var(--radius-sm); 85 | background: transparent; 86 | cursor: pointer; 87 | position: relative; 88 | transition: all var(--transition-fast); 89 | display: grid; 90 | place-content: center; 91 | margin: 0; 92 | } 93 | 94 | .settings-input-checkbox input[type="checkbox"]:checked { 95 | background: var(--accent-cyan); 96 | border-color: var(--accent-cyan); 97 | } 98 | 99 | .settings-input-checkbox input[type="checkbox"]::before { 100 | content: ""; 101 | width: 10px; 102 | height: 10px; 103 | transform: scale(0); 104 | transition: 120ms transform ease-in-out; 105 | box-shadow: inset 1em 1em var(--bg-primary); 106 | transform-origin: center; 107 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 108 | } 109 | 110 | .settings-input-checkbox input[type="checkbox"]:checked::before { 111 | transform: scale(1); 112 | } 113 | 114 | .settings-input-checkbox input[type="checkbox"]:hover { 115 | border-color: var(--accent-cyan); 116 | } 117 | 118 | .settings-input-checkbox input[type="checkbox"]:focus { 119 | box-shadow: 0 0 0 2px rgba(0, 217, 177, 0.2); 120 | border-color: var(--accent-cyan); 121 | outline: none; 122 | } 123 | 124 | /* Multiple Select */ 125 | select[multiple] { 126 | min-height: 150px; 127 | padding: var(--space-2); 128 | } 129 | 130 | select[multiple] option { 131 | padding: var(--space-2); 132 | margin-bottom: 2px; 133 | border-radius: var(--radius-sm); 134 | cursor: pointer; 135 | color: var(--text-secondary); 136 | } 137 | 138 | select[multiple] option:checked { 139 | background: var(--accent-cyan); 140 | color: #000000; 141 | } 142 | 143 | select[multiple] option:hover:not(:checked) { 144 | background: var(--bg-card-hover); 145 | color: var(--text-primary); 146 | } 147 | 148 | /* Form Actions */ 149 | .form-actions { 150 | padding-top: var(--space-4); 151 | border-top: 1px solid var(--border-primary); 152 | margin-top: var(--space-6); 153 | } 154 | -------------------------------------------------------------------------------- /pkg/crypto/bdhke.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "errors" 8 | "math" 9 | 10 | "github.com/decred/dcrd/dcrec/secp256k1/v4" 11 | ) 12 | 13 | // This cryptography module was taken form the gonuts project. https://github.com/elnosh/gonuts/blob/main/crypto/bdhke.go 14 | 15 | const DomainSeparator = "Secp256k1_HashToCurve_Cashu_" 16 | 17 | // Generates a secp256k1 point from a message. 18 | 19 | // The point is generated by hashing the message with a domain separator and then 20 | // iteratively trying to compute a point from the hash. An increasing uint32 counter 21 | // (byte order little endian) is appended to the hash until a point is found that lies on the curve. 22 | 23 | // The chance of finding a valid point is 50% for every iteration. The maximum number of iterations 24 | // is 2**16. If no valid point is found after 2**16 iterations, a ValueError is raised (this should 25 | // never happen in practice). 26 | 27 | // The domain separator is b"Secp256k1_HashToCurve_Cashu_" or 28 | // bytes.fromhex("536563703235366b315f48617368546f43757276655f43617368755f"). 29 | func HashToCurve(message []byte) (*secp256k1.PublicKey, error) { 30 | msgToHash := sha256.Sum256(append([]byte(DomainSeparator), message...)) 31 | var counter uint32 = 0 32 | for counter < uint32(math.Exp2(16)) { 33 | // little endian counter 34 | c := make([]byte, 4) 35 | binary.LittleEndian.PutUint32(c, counter) 36 | 37 | hash := sha256.Sum256(append(msgToHash[:], c...)) 38 | pkHash := append([]byte{0x02}, hash[:]...) 39 | point, err := secp256k1.ParsePubKey(pkHash) 40 | if err != nil { 41 | counter++ 42 | continue 43 | } 44 | if point.IsOnCurve() { 45 | return point, nil 46 | } 47 | } 48 | return nil, errors.New("no valid point found") 49 | } 50 | 51 | // B_ = Y + rG 52 | func BlindMessage(secret string, r *secp256k1.PrivateKey) (*secp256k1.PublicKey, 53 | *secp256k1.PrivateKey, error) { 54 | 55 | var ypoint, rpoint, blindedMessage secp256k1.JacobianPoint 56 | Y, err := HashToCurve([]byte(secret)) 57 | if err != nil { 58 | return nil, nil, err 59 | } 60 | Y.AsJacobian(&ypoint) 61 | 62 | rpub := r.PubKey() 63 | rpub.AsJacobian(&rpoint) 64 | 65 | // blindedMessage = Y + rG 66 | secp256k1.AddNonConst(&ypoint, &rpoint, &blindedMessage) 67 | blindedMessage.ToAffine() 68 | B_ := secp256k1.NewPublicKey(&blindedMessage.X, &blindedMessage.Y) 69 | 70 | return B_, r, nil 71 | } 72 | 73 | // C_ = kB_ 74 | func SignBlindedMessage(B_ *secp256k1.PublicKey, k *secp256k1.PrivateKey) *secp256k1.PublicKey { 75 | var bpoint, result secp256k1.JacobianPoint 76 | B_.AsJacobian(&bpoint) 77 | 78 | secp256k1.ScalarMultNonConst(&k.Key, &bpoint, &result) 79 | result.ToAffine() 80 | C_ := secp256k1.NewPublicKey(&result.X, &result.Y) 81 | 82 | return C_ 83 | } 84 | 85 | // C = C_ - rK 86 | func UnblindSignature(C_ *secp256k1.PublicKey, r *secp256k1.PrivateKey, 87 | K *secp256k1.PublicKey) *secp256k1.PublicKey { 88 | 89 | var Kpoint, rKPoint, CPoint secp256k1.JacobianPoint 90 | K.AsJacobian(&Kpoint) 91 | 92 | var rNeg secp256k1.ModNScalar 93 | rNeg.NegateVal(&r.Key) 94 | 95 | secp256k1.ScalarMultNonConst(&rNeg, &Kpoint, &rKPoint) 96 | 97 | var C_Point secp256k1.JacobianPoint 98 | C_.AsJacobian(&C_Point) 99 | secp256k1.AddNonConst(&C_Point, &rKPoint, &CPoint) 100 | CPoint.ToAffine() 101 | 102 | C := secp256k1.NewPublicKey(&CPoint.X, &CPoint.Y) 103 | return C 104 | } 105 | 106 | // k * HashToCurve(secret) == C 107 | func Verify(secret string, k *secp256k1.PrivateKey, C *secp256k1.PublicKey) bool { 108 | Y, err := HashToCurve([]byte(secret)) 109 | if err != nil { 110 | return false 111 | } 112 | valid := verify(Y, k, C) 113 | 114 | return valid 115 | } 116 | 117 | func verify(Y *secp256k1.PublicKey, k *secp256k1.PrivateKey, C *secp256k1.PublicKey) bool { 118 | var Ypoint, result secp256k1.JacobianPoint 119 | Y.AsJacobian(&Ypoint) 120 | 121 | secp256k1.ScalarMultNonConst(&k.Key, &Ypoint, &result) 122 | result.ToAffine() 123 | pk := secp256k1.NewPublicKey(&result.X, &result.Y) 124 | 125 | return C.IsEqual(pk) 126 | } 127 | 128 | // DLEQ HASH 129 | func Hash_e(pubkeys []*secp256k1.PublicKey) [32]byte { 130 | e_ := "" 131 | for _, pubkey := range pubkeys { 132 | _p := pubkey.SerializeUncompressed() 133 | 134 | e_ += hex.EncodeToString(_p) 135 | } 136 | 137 | e_bytes := []byte(e_) 138 | 139 | e := sha256.Sum256(e_bytes) 140 | 141 | return e 142 | } 143 | -------------------------------------------------------------------------------- /internal/routes/auth.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/lescuer97/nutmix/api/cashu" 10 | m "github.com/lescuer97/nutmix/internal/mint" 11 | "github.com/lescuer97/nutmix/internal/utils" 12 | ) 13 | 14 | func AuthActivatedMiddleware(mint *m.Mint) gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | 17 | if !mint.Config.MINT_REQUIRE_AUTH { 18 | slog.Warn(fmt.Errorf("tried using route that does not exist because auth not being active").Error()) 19 | c.JSON(404, "route does not exists") 20 | c.Abort() 21 | return 22 | } 23 | c.Next() 24 | } 25 | } 26 | 27 | func v1AuthRoutes(r *gin.Engine, mint *m.Mint) { 28 | v1 := r.Group("/v1") 29 | auth := v1.Group("/auth") 30 | auth.Use(AuthActivatedMiddleware(mint)) 31 | 32 | auth.GET("/blind/keys", func(c *gin.Context) { 33 | keys, err := mint.Signer.GetAuthActiveKeys() 34 | if err != nil { 35 | slog.Error("mint.Signer.GetAuthActiveKeys()", slog.Any("error", err)) 36 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.KEYSET_NOT_KNOW, nil)) 37 | return 38 | } 39 | 40 | c.JSON(200, keys) 41 | }) 42 | 43 | auth.GET("/blind/keys/:id", func(c *gin.Context) { 44 | id := c.Param("id") 45 | 46 | keysets, err := mint.Signer.GetAuthKeysById(id) 47 | 48 | if err != nil { 49 | slog.Error("mint.Signer.GetAuthKeysById(id)", slog.Any("error", err)) 50 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.KEYSET_NOT_KNOW, nil)) 51 | return 52 | } 53 | 54 | c.JSON(200, keysets) 55 | }) 56 | 57 | auth.GET("/blind/keysets", func(c *gin.Context) { 58 | keys, err := mint.Signer.GetAuthKeys() 59 | if err != nil { 60 | slog.Error("mint.Signer.GetAuthKeys()", slog.Any("error", err)) 61 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.KEYSET_NOT_KNOW, nil)) 62 | return 63 | } 64 | 65 | c.JSON(200, keys) 66 | }) 67 | 68 | auth.POST("/blind/mint", func(c *gin.Context) { 69 | var mintRequest cashu.PostMintBolt11Request 70 | err := c.BindJSON(&mintRequest) 71 | if err != nil { 72 | slog.Info("Incorrect body", slog.Any("error", err)) 73 | c.JSON(400, "Malformed body request") 74 | return 75 | } 76 | 77 | ctx := context.Background() 78 | tx, err := mint.MintDB.GetTx(ctx) 79 | if err != nil { 80 | _ = c.Error(fmt.Errorf("m.MintDB.GetTx(ctx). %w", err)) 81 | return 82 | } 83 | defer func() { 84 | if err != nil { 85 | if rollbackErr := mint.MintDB.Rollback(ctx, tx); rollbackErr != nil { 86 | slog.Warn("rollback error", slog.Any("error", rollbackErr)) 87 | } 88 | } 89 | }() 90 | 91 | keysets, err := mint.Signer.GetAuthKeys() 92 | if err != nil { 93 | slog.Error("mint.Signer.GetKeys()", slog.Any("error", err)) 94 | errorCode, details := utils.ParseErrorToCashuErrorCode(err) 95 | c.JSON(400, cashu.ErrorCodeToResponse(errorCode, details)) 96 | return 97 | } 98 | unit, err := mint.VerifyOutputs(tx, mintRequest.Outputs, keysets.Keysets) 99 | if err != nil { 100 | slog.Error("mint.VerifyOutputs(mintRequest.Outputs)", slog.Any("error", err)) 101 | errorCode, details := utils.ParseErrorToCashuErrorCode(err) 102 | c.JSON(400, cashu.ErrorCodeToResponse(errorCode, details)) 103 | return 104 | } 105 | 106 | if unit != cashu.AUTH { 107 | details := `You can only use "auth" tokens in this endpoint` 108 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.UNIT_NOT_SUPPORTED, &details)) 109 | return 110 | } 111 | 112 | amountBlindMessages := uint64(0) 113 | 114 | for _, blindMessage := range mintRequest.Outputs { 115 | amountBlindMessages += blindMessage.Amount 116 | // check all blind messages have the same unit 117 | } 118 | 119 | if amountBlindMessages > uint64(mint.Config.MINT_AUTH_MAX_BLIND_TOKENS) { 120 | slog.Warn("Trying to mint auth tokens over the limit") 121 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.MAXIMUM_BAT_MINT_LIMIT_EXCEEDED, nil)) 122 | return 123 | } 124 | 125 | blindedSignatures, recoverySigsDb, err := mint.Signer.SignBlindMessages(mintRequest.Outputs) 126 | if err != nil { 127 | slog.Error("mint.Signer.SignBlindMessages(mintRequest.Outputs)", slog.Any("error", err)) 128 | errorCode, details := utils.ParseErrorToCashuErrorCode(err) 129 | c.JSON(400, cashu.ErrorCodeToResponse(errorCode, details)) 130 | return 131 | } 132 | 133 | err = mint.MintDB.SaveRestoreSigs(tx, recoverySigsDb) 134 | if err != nil { 135 | slog.Error("SetRecoverySigs on minting", slog.Any("error", err)) 136 | slog.Error("recoverySigsDb", slog.Any("recovery_sigs", recoverySigsDb)) 137 | return 138 | } 139 | 140 | err = mint.MintDB.Commit(ctx, tx) 141 | if err != nil { 142 | _ = c.Error(fmt.Errorf("mint.MintDB.Commit(ctx tx). %w", err)) 143 | return 144 | } 145 | 146 | c.JSON(200, cashu.PostMintBolt11Response{ 147 | Signatures: blindedSignatures, 148 | }) 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /api/cashu/melt_test.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "encoding/json" 7 | "testing" 8 | ) 9 | 10 | // This are NUT-11 Test Vectors 11 | func TestMeltRequestMsg(t *testing.T) { 12 | meltRequestJson := []byte(`{ 13 | "quote": "cF8911fzT88aEi1d-6boZZkq5lYxbUSVs-HbJxK0", 14 | "inputs": [ 15 | { 16 | "amount": 2, 17 | "id": "00bfa73302d12ffd", 18 | "secret": "[\"P2PK\",{\"nonce\":\"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de\",\"data\":\"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", 19 | "C": "02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b", 20 | "witness": "{\"signatures\":[\"478224fbe715e34f78cb33451db6fcf8ab948afb8bd04ff1a952c92e562ac0f7c1cb5e61809410635be0aa94d0448f7f7959bd5762cc3802b0a00ff58b2da747\"]}" 21 | } 22 | ], 23 | "outputs": [ 24 | { 25 | "amount": 0, 26 | "id": "00bfa73302d12ffd", 27 | "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" 28 | } 29 | ] 30 | }`) 31 | 32 | var meltRequest PostMeltBolt11Request 33 | err := json.Unmarshal(meltRequestJson, &meltRequest) 34 | if err != nil { 35 | t.Fatalf("could not marshall %+v", meltRequestJson) 36 | } 37 | msg := meltRequest.makeSigAllMsg() 38 | if msg != `["P2PK",{"nonce":"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de","data":"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835","tags":[["sigflag","SIG_ALL"]]}]02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b0038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39cF8911fzT88aEi1d-6boZZkq5lYxbUSVs-HbJxK0` { 39 | t.Errorf("Message is not correct %v", msg) 40 | } 41 | 42 | hashMessage := sha256.Sum256([]byte(msg)) 43 | 44 | if hex.EncodeToString(hashMessage[:]) != "9efa1067cc7dc870f4074f695115829c3cd817a6866c3b84e9814adf3c3cf262" { 45 | t.Errorf("hash message is wrong %v", msg) 46 | } 47 | 48 | } 49 | 50 | func TestMeltRequestValidSignature(t *testing.T) { 51 | meltRequestJson := []byte(`{ 52 | "quote": "cF8911fzT88aEi1d-6boZZkq5lYxbUSVs-HbJxK0", 53 | "inputs": [ 54 | { 55 | "amount": 2, 56 | "id": "00bfa73302d12ffd", 57 | "secret": "[\"P2PK\",{\"nonce\":\"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de\",\"data\":\"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", 58 | "C": "02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b", 59 | "witness": "{\"signatures\":[\"478224fbe715e34f78cb33451db6fcf8ab948afb8bd04ff1a952c92e562ac0f7c1cb5e61809410635be0aa94d0448f7f7959bd5762cc3802b0a00ff58b2da747\"]}" 60 | } 61 | ], 62 | "outputs": [ 63 | { 64 | "amount": 0, 65 | "id": "00bfa73302d12ffd", 66 | "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" 67 | } 68 | ] 69 | }`) 70 | 71 | var meltRequest PostMeltBolt11Request 72 | err := json.Unmarshal(meltRequestJson, &meltRequest) 73 | if err != nil { 74 | t.Fatalf("could not marshall PostMeltRequest %+v", meltRequest) 75 | } 76 | 77 | err = meltRequest.ValidateSigflag() 78 | if err != nil { 79 | t.Errorf("the should not have been an error while validating %+v", err) 80 | } 81 | } 82 | 83 | func TestMeltRequestValidMultiSig(t *testing.T) { 84 | meltRequestJson := []byte(`{ 85 | "quote": "Db3qEMVwFN2tf_1JxbZp29aL5cVXpSMIwpYfyOVF", 86 | "inputs": [ 87 | { 88 | "amount": 2, 89 | "id": "00bfa73302d12ffd", 90 | "secret": "[\"P2PK\",{\"nonce\":\"68d7822538740e4f9c9ebf5183ef6c4501c7a9bca4e509ce2e41e1d62e7b8a99\",\"data\":\"0394e841bd59aeadce16380df6174cb29c9fea83b0b65b226575e6d73cc5a1bd59\",\"tags\":[[\"pubkeys\",\"033d892d7ad2a7d53708b7a5a2af101cbcef69522bd368eacf55fcb4f1b0494058\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", 91 | "C": "03a70c42ec9d7192422c7f7a3ad017deda309fb4a2453fcf9357795ea706cc87a9", 92 | "witness": "{\"signatures\":[\"ed739970d003f703da2f101a51767b63858f4894468cc334be04aa3befab1617a81e3eef093441afb499974152d279e59d9582a31dc68adbc17ffc22a2516086\",\"f9efe1c70eb61e7ad8bd615c50ff850410a4135ea73ba5fd8e12a734743ad045e575e9e76ea5c52c8e7908d3ad5c0eaae93337e5c11109e52848dc328d6757a2\"]}" 93 | } 94 | ], 95 | "outputs": [ 96 | { 97 | "amount": 0, 98 | "id": "00bfa73302d12ffd", 99 | "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" 100 | } 101 | ] 102 | }`) 103 | 104 | var meltRequest PostMeltBolt11Request 105 | err := json.Unmarshal(meltRequestJson, &meltRequest) 106 | if err != nil { 107 | t.Fatalf("could not marshall PostSwapRequest %+v", meltRequest) 108 | } 109 | 110 | err = meltRequest.ValidateSigflag() 111 | if err != nil { 112 | t.Errorf("there should not have been any error on multisig! %+v ", err) 113 | 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /internal/utils/common.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "github.com/lescuer97/nutmix/internal/lightning" 7 | "os" 8 | ) 9 | 10 | const ConfigFileName string = "config.toml" 11 | const ConfigDirName string = "nutmix" 12 | const LogFileName string = "nutmix.log" 13 | 14 | type LightningBackend string 15 | 16 | const FAKE_WALLET LightningBackend = "FakeWallet" 17 | const LNDGRPC LightningBackend = "LndGrpcWallet" 18 | const LNBITS LightningBackend = "LNbitsWallet" 19 | const CLNGRPC LightningBackend = "ClnGrpcWallet" 20 | const Strike LightningBackend = "Strike" 21 | 22 | func StringToLightningBackend(text string) LightningBackend { 23 | 24 | switch text { 25 | case string(FAKE_WALLET): 26 | return FAKE_WALLET 27 | case string(LNDGRPC): 28 | return LNDGRPC 29 | case string(LNBITS): 30 | return LNBITS 31 | case string(Strike): 32 | return Strike 33 | default: 34 | return FAKE_WALLET 35 | 36 | } 37 | } 38 | 39 | type Config struct { 40 | NAME string `db:"name"` 41 | IconUrl *string `db:"icon_url,omitempty"` 42 | TosUrl *string `db:"tos_url,omitempty"` 43 | DESCRIPTION string `db:"description"` 44 | DESCRIPTION_LONG string `db:"description_long"` 45 | MOTD string `db:"motd"` 46 | EMAIL string `db:"email"` 47 | NOSTR string `db:"nostr"` 48 | 49 | NETWORK string `db:"network"` 50 | 51 | MINT_LIGHTNING_BACKEND LightningBackend `db:"mint_lightning_backend"` 52 | LND_GRPC_HOST string `db:"lnd_grpc_host"` 53 | LND_TLS_CERT string `db:"lnd_tls_cert"` 54 | LND_MACAROON string `db:"lnd_macaroon"` 55 | 56 | MINT_LNBITS_ENDPOINT string `db:"mint_lnbits_endpoint"` 57 | MINT_LNBITS_KEY string `db:"mint_lnbits_key"` 58 | 59 | CLN_GRPC_HOST string `db:"cln_grpc_host"` 60 | CLN_CA_CERT string `db:"cln_ca_cert"` 61 | CLN_CLIENT_CERT string `db:"cln_client_cert"` 62 | CLN_CLIENT_KEY string `db:"cln_client_key"` 63 | CLN_MACAROON string `db:"cln_macaroon"` 64 | 65 | STRIKE_KEY string `db:"strike_key"` 66 | STRIKE_ENDPOINT string `db:"strike_endpoint"` 67 | 68 | PEG_OUT_ONLY bool `db:"peg_out_only"` 69 | PEG_OUT_LIMIT_SATS *int `db:"peg_out_limit_sats,omitempty"` 70 | PEG_IN_LIMIT_SATS *int `db:"peg_in_limit_sats,omitempty"` 71 | 72 | MINT_REQUIRE_AUTH bool `db:"mint_require_auth,omitempty"` 73 | MINT_AUTH_OICD_URL string `db:"mint_auth_oicd_url,omitempty"` 74 | MINT_AUTH_OICD_CLIENT_ID string `db:"mint_auth_oicd_client_id,omitempty"` 75 | MINT_AUTH_RATE_LIMIT_PER_MINUTE int `db:"mint_auth_rate_limit_per_minute,omitempty"` 76 | MINT_AUTH_MAX_BLIND_TOKENS uint64 `db:"mint_auth_max_blind_tokens,omitempty"` 77 | 78 | MINT_AUTH_CLEAR_AUTH_URLS []string `db:"mint_auth_clear_auth_urls,omitempty"` 79 | MINT_AUTH_BLIND_AUTH_URLS []string `db:"mint_auth_blind_auth_urls,omitempty"` 80 | } 81 | 82 | func (c *Config) Default() { 83 | c.NAME = "" 84 | c.DESCRIPTION = "" 85 | c.IconUrl = nil 86 | c.TosUrl = nil 87 | c.DESCRIPTION_LONG = "" 88 | c.MOTD = "" 89 | c.EMAIL = "" 90 | c.NOSTR = "" 91 | 92 | c.NETWORK = lightning.MAINNET 93 | 94 | c.MINT_LIGHTNING_BACKEND = FAKE_WALLET 95 | 96 | c.LND_GRPC_HOST = "" 97 | c.LND_TLS_CERT = "" 98 | c.LND_MACAROON = "" 99 | 100 | c.MINT_LNBITS_ENDPOINT = "" 101 | c.MINT_LNBITS_KEY = "" 102 | 103 | c.PEG_OUT_ONLY = false 104 | c.PEG_OUT_LIMIT_SATS = nil 105 | c.PEG_IN_LIMIT_SATS = nil 106 | 107 | c.MINT_REQUIRE_AUTH = false 108 | c.MINT_AUTH_OICD_CLIENT_ID = "" 109 | c.MINT_AUTH_MAX_BLIND_TOKENS = 100 110 | c.MINT_AUTH_OICD_URL = "" 111 | c.MINT_AUTH_RATE_LIMIT_PER_MINUTE = 5 112 | c.MINT_AUTH_CLEAR_AUTH_URLS = []string{} 113 | c.MINT_AUTH_BLIND_AUTH_URLS = []string{} 114 | c.STRIKE_KEY = "" 115 | } 116 | 117 | func (c *Config) UseEnviromentVars() { 118 | c.NAME = os.Getenv("NAME") 119 | c.DESCRIPTION = os.Getenv("DESCRIPTION") 120 | c.IconUrl = nil 121 | c.TosUrl = nil 122 | c.DESCRIPTION_LONG = os.Getenv("DESCRIPTION_LONG") 123 | c.MOTD = os.Getenv("MOTD") 124 | c.EMAIL = os.Getenv("EMAIL") 125 | c.NOSTR = os.Getenv("NOSTR") 126 | 127 | c.NETWORK = os.Getenv("NETWORK") 128 | 129 | c.MINT_LIGHTNING_BACKEND = StringToLightningBackend(os.Getenv("MINT_LIGHTNING_BACKEND")) 130 | 131 | c.LND_GRPC_HOST = os.Getenv("LND_GRPC_HOST") 132 | c.LND_TLS_CERT = os.Getenv("LND_TLS_CERT") 133 | c.LND_MACAROON = os.Getenv("LND_MACAROON") 134 | 135 | c.MINT_LNBITS_ENDPOINT = os.Getenv("MINT_LNBITS_ENDPOINT") 136 | c.MINT_LNBITS_KEY = os.Getenv("MINT_LNBITS_KEY") 137 | 138 | } 139 | func RandomHash() (string, error) { 140 | // Create a byte slice of 30 random bytes 141 | randomBytes := make([]byte, 30) 142 | _, err := rand.Read(randomBytes) 143 | if err != nil { 144 | return "", err 145 | } 146 | 147 | // Encode the random bytes as base64-urlsafe string 148 | return base64.URLEncoding.EncodeToString(randomBytes), nil 149 | } 150 | -------------------------------------------------------------------------------- /internal/lightning/fake_wallet.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "slices" 7 | 8 | "github.com/btcsuite/btcd/chaincfg" 9 | "github.com/google/uuid" 10 | "github.com/lescuer97/nutmix/api/cashu" 11 | "github.com/lightningnetwork/lnd/zpay32" 12 | ) 13 | 14 | type FakeWalletError int 15 | 16 | const ( 17 | NONE = 0 18 | 19 | FailPaymentPending = iota + 1 20 | FailPaymentFailed = iota + 2 21 | FailPaymentUnknown = iota + 3 22 | 23 | FailQueryPending = iota + 4 24 | FailQueryFailed = iota + 5 25 | FailQueryUnknown = iota + 6 26 | ) 27 | 28 | type FakeWallet struct { 29 | Network chaincfg.Params 30 | UnpurposeErrors []FakeWalletError 31 | InvoiceFee uint64 32 | } 33 | 34 | const mock_preimage = "fakewalletpreimage" 35 | 36 | func (f FakeWallet) PayInvoice(melt_quote cashu.MeltRequestDB, zpayInvoice *zpay32.Invoice, feeReserve uint64, mpp bool, amount cashu.Amount) (PaymentResponse, error) { 37 | switch { 38 | case slices.Contains(f.UnpurposeErrors, FailPaymentUnknown): 39 | return PaymentResponse{ 40 | Preimage: "", 41 | PaymentRequest: "", 42 | PaymentState: UNKNOWN, 43 | Rhash: "", 44 | PaidFeeSat: 0, 45 | }, nil 46 | 47 | case slices.Contains(f.UnpurposeErrors, FailPaymentFailed): 48 | return PaymentResponse{ 49 | Preimage: "", 50 | PaymentRequest: "", 51 | PaymentState: FAILED, 52 | Rhash: "", 53 | PaidFeeSat: 0, 54 | }, nil 55 | case slices.Contains(f.UnpurposeErrors, FailPaymentPending): 56 | return PaymentResponse{ 57 | Preimage: "", 58 | PaymentRequest: "", 59 | PaymentState: PENDING, 60 | Rhash: "", 61 | PaidFeeSat: 0, 62 | }, nil 63 | } 64 | 65 | return PaymentResponse{ 66 | Preimage: mock_preimage, 67 | PaymentRequest: melt_quote.Request, 68 | PaymentState: SETTLED, 69 | Rhash: "", 70 | PaidFeeSat: 0, 71 | CheckingId: melt_quote.CheckingId, 72 | }, nil 73 | } 74 | 75 | func (f FakeWallet) CheckPayed(quote string, invoice *zpay32.Invoice, checkingId string) (PaymentStatus, string, uint64, error) { 76 | switch { 77 | case slices.Contains(f.UnpurposeErrors, FailQueryUnknown): 78 | return UNKNOWN, "", 0, nil 79 | case slices.Contains(f.UnpurposeErrors, FailQueryFailed): 80 | return FAILED, "", 0, nil 81 | case slices.Contains(f.UnpurposeErrors, FailQueryPending): 82 | return PENDING, "", 0, nil 83 | 84 | } 85 | 86 | return SETTLED, mock_preimage, uint64(10), nil 87 | } 88 | 89 | func (f FakeWallet) CheckReceived(quote cashu.MintRequestDB, invoice *zpay32.Invoice) (PaymentStatus, string, error) { 90 | switch { 91 | case slices.Contains(f.UnpurposeErrors, FailQueryUnknown): 92 | return UNKNOWN, "", nil 93 | case slices.Contains(f.UnpurposeErrors, FailQueryFailed): 94 | return FAILED, "", nil 95 | case slices.Contains(f.UnpurposeErrors, FailQueryPending): 96 | return PENDING, "", nil 97 | 98 | } 99 | 100 | return SETTLED, mock_preimage, nil 101 | } 102 | 103 | func (f FakeWallet) QueryFees(invoice string, zpayInvoice *zpay32.Invoice, mpp bool, amount cashu.Amount) (FeesResponse, error) { 104 | fee := GetFeeReserve(amount.Amount, f.InvoiceFee) 105 | hash := zpayInvoice.PaymentHash[:] 106 | feesResponse := FeesResponse{} 107 | feesResponse.Fees.Amount = fee 108 | feesResponse.AmountToSend.Amount = amount.Amount 109 | feesResponse.CheckingId = hex.EncodeToString(hash) 110 | 111 | return feesResponse, nil 112 | } 113 | 114 | func (f FakeWallet) RequestInvoice(quote cashu.MintRequestDB, amount cashu.Amount) (InvoiceResponse, error) { 115 | var response InvoiceResponse 116 | supported := f.VerifyUnitSupport(amount.Unit) 117 | if !supported { 118 | return response, fmt.Errorf("l.VerifyUnitSupport(amount.Unit). %w", cashu.ErrUnitNotSupported) 119 | } 120 | 121 | expireTime := cashu.ExpiryTimeMinUnit(15) 122 | 123 | description := "mock invoice" 124 | if quote.Description != nil { 125 | description = *quote.Description 126 | } 127 | payReq, err := CreateMockInvoice(amount.Amount, description, f.Network, expireTime) 128 | if err != nil { 129 | return response, fmt.Errorf(`CreateMockInvoice(amount, "mock invoice", f.Network, expireTime). %w`, err) 130 | } 131 | 132 | randUuid, err := uuid.NewRandom() 133 | 134 | if err != nil { 135 | return response, fmt.Errorf(`uuid.NewRandom() %w`, err) 136 | } 137 | 138 | return InvoiceResponse{ 139 | PaymentRequest: payReq, 140 | Rhash: randUuid.String(), 141 | CheckingId: payReq, 142 | }, nil 143 | } 144 | 145 | func (f FakeWallet) WalletBalance() (uint64, error) { 146 | return 0, nil 147 | } 148 | 149 | func (f FakeWallet) LightningType() Backend { 150 | return FAKEWALLET 151 | } 152 | 153 | func (f FakeWallet) GetNetwork() *chaincfg.Params { 154 | return &f.Network 155 | } 156 | 157 | func (f FakeWallet) ActiveMPP() bool { 158 | return false 159 | } 160 | func (f FakeWallet) VerifyUnitSupport(unit cashu.Unit) bool { 161 | return true 162 | } 163 | func (f FakeWallet) DescriptionSupport() bool { 164 | return true 165 | } 166 | -------------------------------------------------------------------------------- /api/cashu/errors.go: -------------------------------------------------------------------------------- 1 | package cashu 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | ) 7 | 8 | var ( 9 | ErrMeltAlreadyPaid = errors.New("melt already paid") 10 | ErrQuoteIsPending = errors.New("quote is pending") 11 | ErrUnitNotSupported = errors.New("unit not supported") 12 | ErrDifferentInputOutputUnit = errors.New("different input output unit") 13 | ErrNotEnoughtProofs = errors.New("not enough proofs") 14 | ErrProofSpent = errors.New("proof already spent") 15 | ErrBlindMessageAlreadySigned = errors.New("blind message already signed") 16 | ErrCommonSecretNotCorrectSize = errors.New("proof secret is not correct size") 17 | ErrUnknown = errors.New("unknown error") 18 | ) 19 | 20 | type ErrorCode uint 21 | 22 | const ( 23 | PROOF_VERIFICATION_FAILED ErrorCode = 10001 24 | 25 | PROOF_ALREADY_SPENT ErrorCode = 11001 26 | PROOFS_PENDING ErrorCode = 11002 27 | OUTPUTS_ALREADY_SIGNED ErrorCode = 11003 28 | OUTPUTS_PENDING ErrorCode = 11004 29 | TRANSACTION_NOT_BALANCED ErrorCode = 11005 30 | INSUFICIENT_FEE ErrorCode = 11006 31 | DUPLICATE_INPUTS ErrorCode = 11007 32 | DUPLICATE_OUTPUTS ErrorCode = 11008 33 | MULTIPLE_UNITS_OUTPUT_INPUT ErrorCode = 11009 34 | INPUT_OUTPUT_NOT_SAME_UNIT ErrorCode = 11010 35 | UNIT_NOT_SUPPORTED ErrorCode = 11013 36 | 37 | KEYSET_NOT_KNOW ErrorCode = 12001 38 | INACTIVE_KEYSET ErrorCode = 12002 39 | 40 | REQUEST_NOT_PAID ErrorCode = 20001 41 | QUOTE_ALREADY_ISSUED ErrorCode = 20002 42 | MINTING_DISABLED ErrorCode = 20003 43 | LIGHTNING_PAYMENT_FAILED ErrorCode = 20004 44 | QUOTE_PENDING ErrorCode = 20005 45 | INVOICE_ALREADY_PAID ErrorCode = 20006 46 | 47 | MINT_QUOTE_INVALID_SIG ErrorCode = 20008 48 | MINT_QUOTE_INVALID_PUB_KEY ErrorCode = 20009 49 | 50 | ENDPOINT_REQUIRES_CLEAR_AUTH ErrorCode = 30001 51 | CLEAR_AUTH_FAILED ErrorCode = 30002 52 | 53 | ENDPOINT_REQUIRES_BLIND_AUTH ErrorCode = 31001 54 | BLIND_AUTH_FAILED ErrorCode = 31002 55 | MAXIMUM_BAT_MINT_LIMIT_EXCEEDED ErrorCode = 31003 56 | MAXIMUM_BAT_RATE_LIMIT_EXCEEDED ErrorCode = 31004 57 | 58 | UNKNOWN ErrorCode = 99999 59 | ) 60 | 61 | func (e ErrorCode) String() string { 62 | 63 | error := "" 64 | switch e { 65 | case OUTPUTS_ALREADY_SIGNED: 66 | error = "Blinded message of output already signed" 67 | case PROOF_VERIFICATION_FAILED: 68 | error = "Proof could not be verified" 69 | 70 | case PROOF_ALREADY_SPENT: 71 | error = "Proof is already spent" 72 | case PROOFS_PENDING: 73 | error = "Proofs are pending" 74 | case OUTPUTS_PENDING: 75 | error = "Outputs are pending" 76 | case TRANSACTION_NOT_BALANCED: 77 | error = "Transaction is not balanced (inputs != outputs)" 78 | case UNIT_NOT_SUPPORTED: 79 | error = "Unit in request is not supported" 80 | case INSUFICIENT_FEE: 81 | error = "Insufficient fee" 82 | case DUPLICATE_INPUTS: 83 | error = "Duplicate inputs provided" 84 | case DUPLICATE_OUTPUTS: 85 | error = "Duplicate inputs provided" 86 | case MULTIPLE_UNITS_OUTPUT_INPUT: 87 | error = "Inputs/Outputs of multiple units" 88 | case INPUT_OUTPUT_NOT_SAME_UNIT: 89 | error = "Inputs and outputs are not same unit" 90 | 91 | case KEYSET_NOT_KNOW: 92 | error = "Keyset is not known" 93 | case INACTIVE_KEYSET: 94 | error = "Keyset is inactive, cannot sign messages" 95 | case MINT_QUOTE_INVALID_SIG: 96 | error = "No valid signature was provided" 97 | case MINT_QUOTE_INVALID_PUB_KEY: 98 | error = "No public key for mint quote" 99 | 100 | case REQUEST_NOT_PAID: 101 | error = "Quote request is not paid" 102 | case QUOTE_ALREADY_ISSUED: 103 | error = "Quote has already been issued" 104 | case MINTING_DISABLED: 105 | error = "Minting is disabled" 106 | case QUOTE_PENDING: 107 | error = "Quote is pending" 108 | case INVOICE_ALREADY_PAID: 109 | error = "Invoice already paid" 110 | 111 | case ENDPOINT_REQUIRES_CLEAR_AUTH: 112 | error = "Endpoint requires clear auth" 113 | case CLEAR_AUTH_FAILED: 114 | error = "Clear authentification failed" 115 | 116 | case ENDPOINT_REQUIRES_BLIND_AUTH: 117 | error = "Endpoint requires blind auth" 118 | case BLIND_AUTH_FAILED: 119 | error = "Blind authentification failed" 120 | case MAXIMUM_BAT_MINT_LIMIT_EXCEEDED: 121 | error = "Maximum Blind auth token amounts execeeded" 122 | case MAXIMUM_BAT_RATE_LIMIT_EXCEEDED: 123 | error = "Maximum BAT rate limit execeeded" 124 | } 125 | 126 | return error 127 | } 128 | 129 | type ErrorResponse struct { 130 | // integer code 131 | Code ErrorCode `json:"code"` 132 | // Human readable error 133 | Error string `json:"error,omitempty"` 134 | // Extended explanation of error 135 | Detail *string `json:"detail,omitempty"` 136 | } 137 | 138 | func ErrorCodeToResponse(code ErrorCode, detail *string) ErrorResponse { 139 | 140 | log.Printf("\n code: %+v \n", code) 141 | return ErrorResponse{ 142 | Code: code, 143 | Error: code.String(), 144 | Detail: detail, 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/routes/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "log/slog" 8 | "regexp" 9 | "time" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/lescuer97/nutmix/api/cashu" 13 | "github.com/lescuer97/nutmix/internal/mint" 14 | ) 15 | 16 | // ClearAuthMiddleware creates a middleware that checks for the "clear auth" header 17 | // but only for paths that match patterns in the specified allowedPathPatterns list 18 | func ClearAuthMiddleware(mint *mint.Mint) gin.HandlerFunc { 19 | return func(c *gin.Context) { 20 | requestPath := c.Request.URL.Path 21 | 22 | if mint.Config.MINT_REQUIRE_AUTH { 23 | // Check if current path matches any of the patterns 24 | for _, pattern := range mint.Config.MINT_AUTH_CLEAR_AUTH_URLS { 25 | if !mint.Config.MINT_REQUIRE_AUTH { 26 | log.Panicf("mint require auth should always be on when using the middleware") 27 | } 28 | 29 | matches, err := matchesPattern(requestPath, pattern) 30 | if err != nil { 31 | log.Panicf("This should not happen and something went wrong %+v. Patten: %s", err, pattern) 32 | } 33 | if matches { 34 | if mint.OICDClient == nil { 35 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 36 | defer cancel() 37 | err := mint.SetupOidcService(ctx, mint.Config.MINT_AUTH_OICD_URL) 38 | if err != nil { 39 | slog.Error("Could not setup oidc service during middleware.", slog.Any("error", err)) 40 | errMsg := "This is a mint connectin error with the oidc service" 41 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.CLEAR_AUTH_FAILED, &errMsg)) 42 | return 43 | } 44 | } 45 | slog.Info("Trying to access restricted route") 46 | // For paths matching the pattern, check for the "clear auth" header 47 | clearAuth := c.GetHeader("Clear-auth") 48 | if clearAuth == "" { 49 | slog.Warn("Tried to do a clear auth without token.") 50 | c.JSON(401, cashu.ErrorCodeToResponse(cashu.ENDPOINT_REQUIRES_CLEAR_AUTH, nil)) 51 | c.Abort() 52 | return 53 | } 54 | // check if it's valid token 55 | token := c.GetHeader("Clear-auth") 56 | err := mint.VerifyAuthClearToken(token) 57 | if err != nil { 58 | slog.Error("mint.VerifyAuthClearToken(token)", slog.Any("error", err)) 59 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.CLEAR_AUTH_FAILED, nil)) 60 | return 61 | } 62 | // Header exists, continue processing 63 | break 64 | } 65 | } 66 | } 67 | 68 | // Continue to the next middleware/handler 69 | c.Next() 70 | } 71 | } 72 | 73 | // ClearAuthMiddleware creates a middleware that checks for the "clear auth" header 74 | // but only for paths that match patterns in the specified allowedPathPatterns list 75 | func BlindAuthMiddleware(mint *mint.Mint) gin.HandlerFunc { 76 | return func(c *gin.Context) { 77 | requestPath := c.Request.URL.Path 78 | 79 | if !mint.Config.MINT_REQUIRE_AUTH { 80 | c.Next() 81 | } 82 | if mint.Config.MINT_REQUIRE_AUTH { 83 | // Check if current path matches any of the patterns 84 | for _, pattern := range mint.Config.MINT_AUTH_BLIND_AUTH_URLS { 85 | if !mint.Config.MINT_REQUIRE_AUTH { 86 | log.Panicf("mint require auth should always be on when using the middleware") 87 | } 88 | matches, err := matchesPattern(requestPath, pattern) 89 | if err != nil { 90 | log.Panicf("This should not happen and something went wrong %+v. Patten: %s", err, pattern) 91 | } 92 | if matches { 93 | slog.Info("Trying to access restricted route") 94 | // For paths matching the pattern, check for the "clear auth" header 95 | blindAuth := c.GetHeader("Blind-auth") 96 | if blindAuth == "" { 97 | slog.Warn("Tried to do a blind auth without token.") 98 | c.JSON(401, cashu.ErrorCodeToResponse(cashu.ENDPOINT_REQUIRES_BLIND_AUTH, nil)) 99 | c.Abort() 100 | return 101 | } 102 | authProof, err := cashu.DecodeAuthToken(blindAuth) 103 | if err != nil { 104 | slog.Warn("cashu.DecodeAuthToken(blindAuth)") 105 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.BLIND_AUTH_FAILED, nil)) 106 | c.Abort() 107 | return 108 | } 109 | 110 | authProof.Amount = 1 111 | err = mint.VerifyAuthBlindToken(authProof) 112 | if err != nil { 113 | slog.Error("mint.VerifyAuthBlindToken(authProof)", slog.Any("error", err)) 114 | c.JSON(400, cashu.ErrorCodeToResponse(cashu.BLIND_AUTH_FAILED, nil)) 115 | return 116 | } 117 | // Header exists, continue processing 118 | break 119 | } 120 | } 121 | } 122 | 123 | // Continue to the next middleware/handler 124 | c.Next() 125 | } 126 | } 127 | 128 | // matchesPattern checks if a path matches a pattern 129 | // Simple implementation that handles wildcards at the end of paths (e.g., /v1/mint/*) 130 | func matchesPattern(path, pattern string) (bool, error) { 131 | regex, err := regexp.Compile(pattern) 132 | if err != nil { 133 | return false, fmt.Errorf("regexp.Compile(pattern). %w", err) 134 | } 135 | return regex.MatchString(path), nil 136 | } 137 | -------------------------------------------------------------------------------- /internal/routes/admin/static/app.css: -------------------------------------------------------------------------------- 1 | /* legacy styles */ 2 | /* @import url("settings.css"); 3 | @import url("bolt11.css"); 4 | @import url("keysets.css"); 5 | @import url("activity.css"); 6 | @import url("dialog.css"); 7 | @import url("liquidity.css"); */ 8 | 9 | /* the new stylings */ 10 | @import url("base.css"); 11 | @import url("card.css"); 12 | @import url("button.css"); 13 | @import url("login.css"); 14 | @import url("header.css"); 15 | @import url("chart.css"); 16 | @import url("expansion.css"); 17 | @import url("settings.css"); 18 | 19 | 20 | #notifications { 21 | position: fixed; 22 | top: 20px; 23 | right: 20px; 24 | z-index: 1000; 25 | display: flex; 26 | flex-direction: column; 27 | gap: 10px; 28 | pointer-events: none; 29 | } 30 | 31 | #snackbar { 32 | position: relative; 33 | background-color: #323232; 34 | color: #fff; 35 | padding: 16px 24px; 36 | border-radius: 4px; 37 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); 38 | font-size: 14px; 39 | min-width: 200px; 40 | max-width: 300px; 41 | opacity: 0; 42 | transform: translateX(120%); 43 | animation: slideInOut 3s ease-in-out forwards; 44 | pointer-events: auto; 45 | } 46 | 47 | #snackbar.success { 48 | background-color: #90ef6e; 49 | color: #000000; 50 | border: 1px solid #7dd87a; 51 | } 52 | 53 | #snackbar.error { 54 | background-color: #ff6666; 55 | color: #000000; 56 | border: 1px solid #ff4444; 57 | } 58 | 59 | @keyframes slideInOut { 60 | 0% { 61 | transform: translateX(120%); 62 | opacity: 0; 63 | } 64 | 10% { 65 | transform: translateX(0); 66 | opacity: 1; 67 | } 68 | 90% { 69 | transform: translateX(0); 70 | opacity: 1; 71 | } 72 | 100% { 73 | transform: translateX(120%); 74 | opacity: 0; 75 | } 76 | } 77 | 78 | .top-divider { 79 | margin-top: var(--spacing-xl); 80 | border-top: 1px solid var(--border-secondary); 81 | padding-top: var(--spacing-xl); 82 | } 83 | 84 | .bottom-divider { 85 | margin-bottom: var(--spacing-xl); 86 | border-bottom: 1px solid var(--border-secondary); 87 | padding-bottom: var(--spacing-xl); 88 | } 89 | 90 | /* ============================================ 91 | TABLE STYLES 92 | ============================================ */ 93 | 94 | .table { 95 | width: 100%; 96 | display: flex; 97 | flex-direction: column; 98 | background: var(--bg-card); 99 | border-radius: var(--radius-lg); 100 | border: 1px solid var(--border-primary); 101 | overflow: hidden; 102 | } 103 | 104 | .table-header { 105 | display: flex; 106 | padding: var(--space-3) var(--space-4); 107 | background: var(--bg-secondary); 108 | border-bottom: 1px solid var(--border-primary); 109 | font-weight: var(--font-semibold); 110 | color: var(--text-secondary); 111 | text-transform: uppercase; 112 | font-size: var(--text-xs); 113 | letter-spacing: 0.05em; 114 | } 115 | 116 | .rows { 117 | display: flex; 118 | flex-direction: column; 119 | } 120 | 121 | 122 | /* make reactive scrollable div with max height of the size of the screen remaining */ 123 | .table .rows { 124 | max-height: calc(100vh - 200px); 125 | min-height: 100px; 126 | overflow-y: auto; 127 | } 128 | 129 | @media (max-height: 768px) { 130 | .table .rows { 131 | max-height: calc(100vh - 200px); 132 | min-height: 100px; 133 | overflow-y: auto; 134 | } 135 | } 136 | 137 | .row-item { 138 | display: flex; 139 | padding: var(--space-3) var(--space-4); 140 | border-bottom: 1px solid var(--border-subtle); 141 | transition: background-color var(--transition-fast); 142 | align-items: center; 143 | text-decoration: none; 144 | color: inherit; 145 | } 146 | 147 | .row-item:last-child { 148 | border-bottom: none; 149 | } 150 | 151 | .row-item:hover { 152 | background-color: var(--bg-card-hover); 153 | } 154 | 155 | .cell { 156 | flex: 1; 157 | overflow: hidden; 158 | text-overflow: ellipsis; 159 | white-space: nowrap; 160 | padding-right: var(--space-4); 161 | font-size: var(--text-sm); 162 | color: var(--text-primary); 163 | display: flex; 164 | align-items: center; 165 | } 166 | 167 | .cell:last-child { 168 | padding-right: 0; 169 | } 170 | 171 | /* Specific cell widths helpers */ 172 | .w-5 { width: 5%; flex: none; } 173 | .w-10 { width: 10%; flex: none; } 174 | .w-15 { width: 15%; flex: none; } 175 | .w-20 { width: 20%; flex: none; } 176 | .w-25 { width: 25%; flex: none; } 177 | .w-30 { width: 30%; flex: none; } 178 | .w-40 { width: 40%; flex: none; } 179 | .w-50 { width: 50%; flex: none; } 180 | .w-60 { width: 60%; flex: none; } 181 | .w-70 { width: 70%; flex: none; } 182 | 183 | /* Action buttons container for pages */ 184 | .page-actions { 185 | display: flex; 186 | gap: var(--space-3); 187 | align-items: center; 188 | } 189 | 190 | .iliquid { 191 | color: var(--accent-red); 192 | } 193 | 194 | /* sliding animation when adding a new keyset */ 195 | .keysets-list { 196 | animation: slideIn 0.3s ease-out forwards; 197 | transform-origin: top; 198 | } 199 | 200 | @keyframes slideIn { 201 | 0% { 202 | opacity: 0; 203 | transform: translateY(-10px); 204 | } 205 | 50% { 206 | opacity: 0.5; 207 | transform: translateY(-5px); 208 | } 209 | 100% { 210 | opacity: 1; 211 | transform: translateY(0); 212 | } 213 | } --------------------------------------------------------------------------------