Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 47 additions & 13 deletions nip47/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions nip47/methods_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading