From c6180b1815c35bc2fc3b24eb992867b0c3955370 Mon Sep 17 00:00:00 2001 From: Jared Tobin Date: Thu, 5 Feb 2026 16:41:40 +0400 Subject: [PATCH 1/4] rfq: add macaroon auth for price oracle client Add support for authenticating the price oracle gRPC client with a macaroon. This allows tapd node operators to secure their price oracle connections on non-private networks. - add NewMacaroonDialOption helper to read a macaroon from disk and convert it to a gRPC per-RPC credential - add PriceOracleMacaroonPath field to CliConfig - extend NewRpcPriceOracle to accept an optional macaroon dial option via fn.Option[grpc.DialOption] - add table-driven tests for NewMacaroonDialOption Refs: #1370 Co-Authored-By: Claude Opus 4.5 --- rfq/cli.go | 2 + rfq/macaroon.go | 34 +++++++++++++++ rfq/macaroon_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++++ rfq/oracle.go | 12 +++++- rfq/oracle_test.go | 10 ++++- 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 rfq/macaroon.go create mode 100644 rfq/macaroon_test.go diff --git a/rfq/cli.go b/rfq/cli.go index 0340e776c..940ec17e1 100644 --- a/rfq/cli.go +++ b/rfq/cli.go @@ -33,6 +33,8 @@ type CliConfig struct { PriceOracleTLSCertPath string `long:"priceoracletlscertpath" description:"Path to a PEM-encoded x509 certificate to use when constructing a TLS connection with a price oracle."` + PriceOracleMacaroonPath string `long:"priceoraclemacaroonpath" description:"Path to the macaroon to use when connecting to the price oracle gRPC server."` + SendPriceHint bool `long:"sendpricehint" description:"Send a price hint from the local price oracle to the RFQ peer when requesting a quote. For privacy reasons, this should only be turned on for self-hosted or trusted price oracles."` PriceOracleSendPeerId bool `long:"priceoraclesendpeerid" description:"Send the peer ID (public key of the peer) to the price oracle when requesting a price rate. For privacy reasons, this should only be turned on for self-hosted or trusted price oracles."` diff --git a/rfq/macaroon.go b/rfq/macaroon.go new file mode 100644 index 000000000..239693bfd --- /dev/null +++ b/rfq/macaroon.go @@ -0,0 +1,34 @@ +package rfq + +import ( + "fmt" + "os" + + "github.com/lightningnetwork/lnd/macaroons" + "google.golang.org/grpc" + "gopkg.in/macaroon.v2" +) + +// NewMacaroonDialOption reads a macaroon file from disk and returns +// a gRPC DialOption that attaches it as per-RPC credentials. +func NewMacaroonDialOption(path string) (grpc.DialOption, error) { + macBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unable to read macaroon "+ + "path: %w", err) + } + + mac := &macaroon.Macaroon{} + if err = mac.UnmarshalBinary(macBytes); err != nil { + return nil, fmt.Errorf("unable to decode "+ + "macaroon: %w", err) + } + + cred, err := macaroons.NewMacaroonCredential(mac) + if err != nil { + return nil, fmt.Errorf("error creating macaroon "+ + "credential: %w", err) + } + + return grpc.WithPerRPCCredentials(cred), nil +} diff --git a/rfq/macaroon_test.go b/rfq/macaroon_test.go new file mode 100644 index 000000000..843bc50bc --- /dev/null +++ b/rfq/macaroon_test.go @@ -0,0 +1,98 @@ +package rfq + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/macaroon.v2" +) + +// testCaseNewMacaroonDialOption is a test case for +// NewMacaroonDialOption. +type testCaseNewMacaroonDialOption struct { + name string + setup func(t *testing.T) string + expectError bool +} + +// runNewMacaroonDialOptionTest runs a single test case. +func runNewMacaroonDialOptionTest(t *testing.T, + tc *testCaseNewMacaroonDialOption) { + + t.Run(tc.name, func(t *testing.T) { + path := tc.setup(t) + opt, err := NewMacaroonDialOption(path) + + if tc.expectError { + require.Error(t, err) + require.Nil(t, opt) + return + } + + require.NoError(t, err) + require.NotNil(t, opt) + }) +} + +// TestNewMacaroonDialOption tests the NewMacaroonDialOption function. +func TestNewMacaroonDialOption(t *testing.T) { + testCases := []*testCaseNewMacaroonDialOption{ + { + name: "valid macaroon", + setup: func(t *testing.T) string { + mac, err := macaroon.New( + []byte("root-key"), + []byte("id"), + "loc", + macaroon.LatestVersion, + ) + require.NoError(t, err) + + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + + p := filepath.Join( + t.TempDir(), "test.macaroon", + ) + err = os.WriteFile( + p, macBytes, 0600, + ) + require.NoError(t, err) + + return p + }, + expectError: false, + }, + { + name: "nonexistent path", + setup: func(t *testing.T) string { + return filepath.Join( + t.TempDir(), "missing.macaroon", + ) + }, + expectError: true, + }, + { + name: "invalid file contents", + setup: func(t *testing.T) string { + p := filepath.Join( + t.TempDir(), "bad.macaroon", + ) + err := os.WriteFile( + p, []byte("not a macaroon"), + 0600, + ) + require.NoError(t, err) + + return p + }, + expectError: true, + }, + } + + for _, tc := range testCases { + runNewMacaroonDialOptionTest(t, tc) + } +} diff --git a/rfq/oracle.go b/rfq/oracle.go index de5809855..7e3732ba3 100644 --- a/rfq/oracle.go +++ b/rfq/oracle.go @@ -199,8 +199,10 @@ type RpcPriceOracle struct { } // NewRpcPriceOracle creates a new RPC price oracle handle given the address -// of the price oracle RPC server. -func NewRpcPriceOracle(addrStr string, tlsConfig *TLSConfig) (*RpcPriceOracle, +// of the price oracle RPC server. An optional macaroon dial option can be +// provided for authentication with the oracle server. +func NewRpcPriceOracle(addrStr string, tlsConfig *TLSConfig, + macaroonOpt fn.Option[grpc.DialOption]) (*RpcPriceOracle, error) { addr, err := ParsePriceOracleAddress(addrStr) @@ -237,6 +239,12 @@ func NewRpcPriceOracle(addrStr string, tlsConfig *TLSConfig) (*RpcPriceOracle, ), } + // If a macaroon dial option is provided, append it to the dial + // options so that the macaroon is sent with every RPC call. + macaroonOpt.WhenSome(func(opt grpc.DialOption) { + dialOpts = append(dialOpts, opt) + }) + // Formulate the server address dial string. serverAddr := fmt.Sprintf("%s:%s", addr.Hostname(), addr.Port()) diff --git a/rfq/oracle_test.go b/rfq/oracle_test.go index 20e8c8794..93b9955cd 100644 --- a/rfq/oracle_test.go +++ b/rfq/oracle_test.go @@ -142,7 +142,10 @@ func runQuerySalePriceTest(t *testing.T, tc *testCaseQuerySalePrice) { // Create a new RPC price oracle client and connect to the mock service. serviceAddr := fmt.Sprintf("rfqrpc://%s", testServiceAddress) insecureTLS := insecureTLS() - client, err := NewRpcPriceOracle(serviceAddr, insecureTLS) + client, err := NewRpcPriceOracle( + serviceAddr, insecureTLS, + fn.None[grpc.DialOption](), + ) require.NoError(t, err) // Query for an ask price. @@ -260,7 +263,10 @@ func runQueryPurchasePriceTest(t *testing.T, tc *testCaseQueryPurchasePrice) { // Create a new RPC price oracle client and connect to the mock service. serviceAddr := fmt.Sprintf("rfqrpc://%s", testServiceAddress) - client, err := NewRpcPriceOracle(serviceAddr, insecureTLS()) + client, err := NewRpcPriceOracle( + serviceAddr, insecureTLS(), + fn.None[grpc.DialOption](), + ) require.NoError(t, err) // Query for an ask price. From 7382a8140a0ea1f11bdb78f02425f7e2fc627006 Mon Sep 17 00:00:00 2001 From: Jared Tobin Date: Thu, 5 Feb 2026 16:41:46 +0400 Subject: [PATCH 2/4] tapcfg: wire price oracle macaroon config - expand PriceOracleMacaroonPath in ExperimentalConfig.CleanAndValidate - add getPriceOracleMacaroonOpt helper to load the macaroon and return it as fn.Option[grpc.DialOption] - pass macaroon option to NewRpcPriceOracle in genServerConfig - document new flag in sample-tapd.conf Refs: #1370 Co-Authored-By: Claude Opus 4.5 --- sample-tapd.conf | 6 ++++-- tapcfg/config.go | 22 ++++++++++++++++++++++ tapcfg/server.go | 7 +++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/sample-tapd.conf b/sample-tapd.conf index c48bf9405..30306e238 100644 --- a/sample-tapd.conf +++ b/sample-tapd.conf @@ -462,8 +462,7 @@ ; Disable TLS for price oracle communication. ; experimental.rfq.priceoracletlsdisable=false -; Skip price oracle certificate verification, yielding an insecure (cleartext) -; channel with the price oracle. Should only be used for testing. +; Skip price oracle certificate verification. Should only be used for testing. ; experimental.rfq.priceoracletlsinsecure=false ; Disable use of the operating system's root CA list when verifying a @@ -474,6 +473,9 @@ ; secure communication with a price oracle. ; experimental.rfq.priceoracletlscertpath= +; Path to the macaroon to use when connecting to the price oracle gRPC server. +; experimental.rfq.priceoraclemacaroonpath= + ; Send a price hint from the local price oracle to the RFQ peer when requesting ; a quote. For privacy reasons, this should only be turned on for self-hosted or ; trusted price oracles. diff --git a/tapcfg/config.go b/tapcfg/config.go index e57bf4433..e41d67bdb 100644 --- a/tapcfg/config.go +++ b/tapcfg/config.go @@ -362,6 +362,8 @@ type ExperimentalConfig struct { func (c *ExperimentalConfig) CleanAndValidate() error { c.Rfq.PriceOracleTLSCertPath = CleanAndExpandPath( c.Rfq.PriceOracleTLSCertPath) + c.Rfq.PriceOracleMacaroonPath = CleanAndExpandPath( + c.Rfq.PriceOracleMacaroonPath) return c.Rfq.Validate() } @@ -1264,6 +1266,26 @@ func getPriceOracleTLSConfig(rfqCfg rfq.CliConfig) (*rfq.TLSConfig, error) { return tlsConfig, nil } +// getPriceOracleMacaroonOpt reads the price oracle macaroon file +// from disk (if configured) and returns it as an optional gRPC dial +// option. +func getPriceOracleMacaroonOpt( + rfqCfg rfq.CliConfig) (fn.Option[grpc.DialOption], error) { + + if rfqCfg.PriceOracleMacaroonPath == "" { + return fn.None[grpc.DialOption](), nil + } + + opt, err := rfq.NewMacaroonDialOption( + rfqCfg.PriceOracleMacaroonPath, + ) + if err != nil { + return fn.None[grpc.DialOption](), err + } + + return fn.Some(opt), nil +} + // fileExists reports whether the named file or directory exists. // This function is taken from https://github.com/btcsuite/btcd func fileExists(name string) bool { diff --git a/tapcfg/server.go b/tapcfg/server.go index b2e75ad0e..89fabadd9 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -520,8 +520,15 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, "oracle configuration: %w", err) } + macaroonOpt, err := getPriceOracleMacaroonOpt(rfqCfg) + if err != nil { + return nil, fmt.Errorf("unable to load price "+ + "oracle macaroon: %w", err) + } + priceOracle, err = rfq.NewRpcPriceOracle( rfqCfg.PriceOracleAddress, tlsConfig, + macaroonOpt, ) if err != nil { return nil, fmt.Errorf("unable to create price "+ From 710ce0e7b9e7e89a7f54e1697aae196a24b63839 Mon Sep 17 00:00:00 2001 From: Jared Tobin Date: Thu, 5 Feb 2026 16:53:16 +0400 Subject: [PATCH 3/4] rfq: reject macaroon config when TLS is disabled A macaroon credential requires transport security. Validate this up front in CliConfig.Validate so users get a clear config error rather than a cryptic gRPC dial failure. Co-Authored-By: Claude Opus 4.5 --- rfq/cli.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rfq/cli.go b/rfq/cli.go index 940ec17e1..88d67921f 100644 --- a/rfq/cli.go +++ b/rfq/cli.go @@ -109,6 +109,14 @@ func (c *CliConfig) Validate() error { } } + // A macaroon requires transport security. If a macaroon path is set + // but TLS is disabled, the gRPC dial will fail. Catch this early with + // a clear error. + if c.PriceOracleMacaroonPath != "" && c.PriceOracleTLSDisable { + return fmt.Errorf("priceoraclemacaroonpath requires " + + "price oracle TLS to be enabled") + } + // Ensure that if the portfolio pilot address is set, it is valid. if c.PortfolioPilotAddress != "" { _, err := ParsePortfolioPilotAddress(c.PortfolioPilotAddress) From 8a45ce501f8d18a17cd984acf2fafc0e99152a18 Mon Sep 17 00:00:00 2001 From: Jared Tobin Date: Thu, 5 Feb 2026 17:03:27 +0400 Subject: [PATCH 4/4] docs: add release note --- docs/release-notes/release-notes-0.8.0.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-0.8.0.md b/docs/release-notes/release-notes-0.8.0.md index 07f0859e3..8efabd703 100644 --- a/docs/release-notes/release-notes-0.8.0.md +++ b/docs/release-notes/release-notes-0.8.0.md @@ -139,6 +139,11 @@ `experimental.rfq.priceoracle*` allow disabling TLS, skipping verification, or specifying custom certificates. +- [PR#1978](https://github.com/lightninglabs/taproot-assets/pull/1978) + also augments price oracle connections with support for macaroon + authentication. A macaroon path can be specified via the + `experimental.rfq.priceoraclemacaroonpath` config option. + ## RPC Updates - [PR#1766](https://github.com/lightninglabs/taproot-assets/pull/1766): @@ -183,7 +188,7 @@ ## Breaking Changes - [PR#1935](https://github.com/lightninglabs/taproot-assets/pull/1935) - Renamed the RFQ configuration option `experimental.rfq.skipacceptquotepricecheck` + Renamed the RFQ configuration option `experimental.rfq.skipacceptquotepricecheck` to `experimental.rfq.skipquoteacceptverify` for improved clarity. Update your configuration files to use the new option name.