From 67804c96b7c122c55998a4d36317f74b92ccdd3f Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:43:11 +0000 Subject: [PATCH] fix(nip47): add fee_skim_mloki and circle_wallet fields dropped by json.Unmarshal lokihub's get_info_controller.go sends a circle_hub-only `circle_wallet: {available_mloki, max_exp_secs, fees_ppm, circle_policy}` object, and its payResponse/Transaction both carry `fee_skim_mloki` for a circle_wallet member's own outgoing payments -- neither had a matching field here, so encoding/json silently dropped both on Unmarshal and every caller (cashctl's `wallet get-info`, `decode --check`) saw them vanish. Adds GetInfoResult.CircleWallet (new CircleWalletInfo type) and FeeSkimMloki on Transaction/PayInvoiceResult/PayKeysendResult. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 12 ++++++ nip47/methods.go | 60 +++++++++++++++++++++------ nip47/methods_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 nip47/methods_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 904f2a3..2277373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Fixed + +- `nip47.GetInfoResult`/`PayInvoiceResult`/`PayKeysendResult`/`Transaction` + had no field for a circle_hub's own `get_info` terms block or a + circle_wallet payment's forwarding-fee skim — `encoding/json` silently + dropped both on unmarshal, so no caller could ever see them. Added + `GetInfoResult.CircleWallet` (new `CircleWalletInfo` type: available + balance, max expiry, fees_ppm, circle policy) and `FeeSkimMloki` on the + three payment-result types. + ## [0.2.9] ### Fixed diff --git a/nip47/methods.go b/nip47/methods.go index 293c76c..57d886d 100644 --- a/nip47/methods.go +++ b/nip47/methods.go @@ -79,19 +79,28 @@ type Notification struct { // make_invoice/make_hold_invoice/lookup_invoice results, list_transactions // result items, and payment_received/payment_sent notification payloads. type Transaction struct { - Type string `json:"type"` - State string `json:"state,omitempty"` - Invoice string `json:"invoice,omitempty"` - Description string `json:"description,omitempty"` - DescriptionHash string `json:"description_hash,omitempty"` - Preimage string `json:"preimage,omitempty"` - PaymentHash string `json:"payment_hash"` - AmountMloki int64 `json:"amount"` - FeesPaidMloki int64 `json:"fees_paid,omitempty"` - CreatedAt int64 `json:"created_at"` - ExpiresAt *int64 `json:"expires_at,omitempty"` - SettledAt *int64 `json:"settled_at,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` + Type string `json:"type"` + State string `json:"state,omitempty"` + Invoice string `json:"invoice,omitempty"` + Description string `json:"description,omitempty"` + DescriptionHash string `json:"description_hash,omitempty"` + Preimage string `json:"preimage,omitempty"` + PaymentHash string `json:"payment_hash"` + AmountMloki int64 `json:"amount"` + FeesPaidMloki int64 `json:"fees_paid,omitempty"` + // FeeSkimMloki is a circle_hub's forwarding-fee cut (CircleHubConfig. + // FeesPpm) debited from a circle_wallet's own outgoing payment, on top of + // FeesPaidMloki (the real Lightning routing fee) — present only for a + // circle_wallet's own outgoing, non-self payments (see lokihub's + // transactions_service.go validateCanPay/nip47/controllers/models.go + // payResponse.FeeSkimMloki, which this mirrors). Without this field, a + // circle member had no way to learn why their balance dropped by more + // than AmountMloki+FeesPaidMloki. + FeeSkimMloki int64 `json:"fee_skim_mloki,omitempty"` + CreatedAt int64 `json:"created_at"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + SettledAt *int64 `json:"settled_at,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` } // TLVRecord is a keysend custom TLV record. @@ -111,6 +120,10 @@ type PayInvoiceParams struct { type PayInvoiceResult struct { Preimage string `json:"preimage"` FeesPaidMloki int64 `json:"fees_paid,omitempty"` + // FeeSkimMloki — see Transaction.FeeSkimMloki's doc comment; identical + // meaning, just on the immediate pay_invoice response rather than a + // list_transactions row. + FeeSkimMloki int64 `json:"fee_skim_mloki,omitempty"` } // PayKeysendParams is the pay_keysend request payload. @@ -125,6 +138,8 @@ type PayKeysendParams struct { type PayKeysendResult struct { Preimage string `json:"preimage"` FeesPaidMloki int64 `json:"fees_paid,omitempty"` + // FeeSkimMloki — see Transaction.FeeSkimMloki's doc comment. + FeeSkimMloki int64 `json:"fee_skim_mloki,omitempty"` } // MultiPayInvoiceItem is one sub-payment of a multi_pay_invoice request. @@ -230,6 +245,25 @@ type GetInfoResult struct { BlockHash string `json:"block_hash,omitempty"` Methods []string `json:"methods"` Notifications []string `json:"notifications,omitempty"` + // CircleWallet is set only when the dialed connection IS a circle_hub's + // own connection (lokihub's get_info_controller.go only attaches it for + // app.Kind == circle_hub, never for an individual circle_wallet member) — + // the terms a prospective/current member needs to evaluate the Hub + // before or after joining. Absent from every other get_info response, + // including a joined member's own `wallet get-info`. + CircleWallet *CircleWalletInfo `json:"circle_wallet,omitempty"` +} + +// CircleWalletInfo is get_info's circle_hub-only terms block — see +// GetInfoResult.CircleWallet's doc comment for when it's populated. +type CircleWalletInfo struct { + AvailableMloki int64 `json:"available_mloki"` + MaxExpSecs int `json:"max_exp_secs"` + // FeesPpm is the circle_hub's configured forwarding fee (parts per + // million of each circle_wallet child's own outgoing, non-self payment — + // see transactions_service.go's CalculateFeeSkimMloki on the Hub side). + FeesPpm int `json:"fees_ppm"` + CirclePolicy string `json:"circle_policy"` } // SignMessageParams is the sign_message request payload. Like diff --git a/nip47/methods_test.go b/nip47/methods_test.go new file mode 100644 index 0000000..bea579e --- /dev/null +++ b/nip47/methods_test.go @@ -0,0 +1,94 @@ +package nip47 + +import ( + "encoding/json" + "testing" +) + +// TestGetInfoResult_CircleWalletRoundTrips guards against exactly the bug +// found auditing cashctl: lokihub's get_info_controller.go sends a +// circle_hub-only `circle_wallet: {available_mloki, max_exp_secs, fees_ppm, +// circle_policy}` object over the wire, but this type had no field to catch +// it — encoding/json silently drops unknown fields on Unmarshal, so every +// caller (cashctl's `wallet get-info`, `decode --check`) saw an empty +// GetInfoResult where the Hub's own response carried real Circle terms. +func TestGetInfoResult_CircleWalletRoundTrips(t *testing.T) { + wire := `{ + "alias": "hub", + "methods": ["get_info", "create_circle_wallet"], + "circle_wallet": { + "available_mloki": 42000, + "max_exp_secs": 86400, + "fees_ppm": 5000, + "circle_policy": "allowlist" + } + }` + var got GetInfoResult + if err := json.Unmarshal([]byte(wire), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.CircleWallet == nil { + t.Fatalf("CircleWallet = nil, want populated — the circle_wallet wire field was dropped") + } + want := CircleWalletInfo{AvailableMloki: 42000, MaxExpSecs: 86400, FeesPpm: 5000, CirclePolicy: "allowlist"} + if *got.CircleWallet != want { + t.Errorf("CircleWallet = %+v, want %+v", *got.CircleWallet, want) + } +} + +// TestGetInfoResult_CircleWalletAbsentForOrdinaryWallet confirms an ordinary +// (non-circle_hub) get_info response — no circle_wallet key at all, the +// common case — leaves CircleWallet nil rather than a zero-valued struct, so +// callers can tell "not a circle_hub connection" apart from "a circle_hub +// with every field legitimately zero." +func TestGetInfoResult_CircleWalletAbsentForOrdinaryWallet(t *testing.T) { + var got GetInfoResult + if err := json.Unmarshal([]byte(`{"alias":"plain wallet","methods":["get_info"]}`), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.CircleWallet != nil { + t.Errorf("CircleWallet = %+v, want nil", got.CircleWallet) + } +} + +// TestPayInvoiceResult_FeeSkimRoundTrips guards the pay_invoice/pay_keysend/ +// list_transactions counterpart of the same bug: lokihub's payResponse and +// nip47/models.Transaction both carry a `fee_skim_mloki` field specifically +// so a circle_wallet member can see the Hub's forwarding-fee cut on top of +// the real routing fee (nip47/controllers/models.go's own doc comment: without +// it "a circle member had no NWC-facing way to learn why their balance +// dropped by more than invoice_amount+fees_paid"). PayInvoiceResult/ +// PayKeysendResult/Transaction here had no matching field. +func TestPayInvoiceResult_FeeSkimRoundTrips(t *testing.T) { + var got PayInvoiceResult + if err := json.Unmarshal([]byte(`{"preimage":"deadbeef","fees_paid":3,"fee_skim_mloki":500}`), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.FeeSkimMloki != 500 { + t.Errorf("FeeSkimMloki = %d, want 500", got.FeeSkimMloki) + } + if got.FeesPaidMloki != 3 { + t.Errorf("FeesPaidMloki = %d, want 3", got.FeesPaidMloki) + } +} + +func TestPayKeysendResult_FeeSkimRoundTrips(t *testing.T) { + var got PayKeysendResult + if err := json.Unmarshal([]byte(`{"preimage":"deadbeef","fee_skim_mloki":250}`), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.FeeSkimMloki != 250 { + t.Errorf("FeeSkimMloki = %d, want 250", got.FeeSkimMloki) + } +} + +func TestTransaction_FeeSkimRoundTrips(t *testing.T) { + var got Transaction + wire := `{"type":"outgoing","payment_hash":"ab12","amount":50000,"fees_paid":3,"fee_skim_mloki":250,"created_at":1}` + if err := json.Unmarshal([]byte(wire), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.FeeSkimMloki != 250 { + t.Errorf("FeeSkimMloki = %d, want 250", got.FeeSkimMloki) + } +}