diff --git a/asset/asset_test.go b/asset/asset_test.go index 841dc2f8a..0f93e8e06 100644 --- a/asset/asset_test.go +++ b/asset/asset_test.go @@ -4,6 +4,9 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "encoding/json" + "os" + "path/filepath" "reflect" "testing" @@ -106,6 +109,8 @@ var ( GroupPubKey: *pubKey, }, } + + assetHexFileName = filepath.Join("testdata", "asset.hex") ) // TestGenesisAssetClassification tests that the multiple forms of genesis asset @@ -1222,3 +1227,23 @@ func TestExternalKeyPubKey(t *testing.T) { }) } } + +// TestDecodeAsset tests that we can decode an asset from a hex file. This is +// mostly useful for debugging purposes. +func TestDecodeAsset(t *testing.T) { + fileContent, err := os.ReadFile(assetHexFileName) + require.NoError(t, err) + + assetBytes, err := hex.DecodeString(string(fileContent)) + require.NoError(t, err) + + var a Asset + err = a.Decode(bytes.NewReader(assetBytes)) + require.NoError(t, err) + + ta := NewTestFromAsset(t, &a) + assetJSON, err := json.MarshalIndent(ta, "", "\t") + require.NoError(t, err) + + t.Logf("Decoded asset: %v", string(assetJSON)) +} diff --git a/asset/testdata/asset.hex b/asset/testdata/asset.hex new file mode 100644 index 000000000..58942c536 --- /dev/null +++ b/asset/testdata/asset.hex @@ -0,0 +1 @@ +0001010265fca6685a399ad7e9088ba3911c5b2f02b07cffc319f8086e3f129fbf647c4537000000011b69746573742d61737365742d63656e74732d7472616e6368652d32811ad3c42f355c915d1fc4ba4ed71337092191431308f975d7acbc88a09ab98100000000000401000603fd138a0901040bad01ab01651145af966796fc5f4e7ec057acd24b38c5d0060bfe8e0c74e4c9464c08993a330000000017e137755dac067b0e1d91e33d077ab3482fbe1c382d424412c4620a8e3455eb02e9fa4e023746d43a7440b4148fb00f83f8b22ecb67625313fcb54442df2bdfb403420140791e35d3b49d0c1a1ec6415ba419f027fb4fcf254773e9c54ba77715a793e33c67175901e020b9ed87ab2161aa17a572def28d638ca3656fd5f27d6fd974ae280e020000102102e9fa4e023746d43a7440b4148fb00f83f8b22ecb67625313fcb54442df2bdfb4112102f37e9d09521076209768a6028aa2b42000b042a0635cb90f257c8b00c34a3688 \ No newline at end of file diff --git a/docs/examples/basic-price-oracle/go.mod b/docs/examples/basic-price-oracle/go.mod index 04bd25836..5dce9e481 100644 --- a/docs/examples/basic-price-oracle/go.mod +++ b/docs/examples/basic-price-oracle/go.mod @@ -94,16 +94,16 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect - github.com/lightninglabs/lndclient v0.19.0-3 // indirect + github.com/lightninglabs/lndclient v0.19.0-4 // indirect github.com/lightninglabs/neutrino v0.16.1 // indirect github.com/lightninglabs/neutrino/cache v1.1.2 // indirect github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb // indirect - github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250417120008-a304be6bad91 // indirect + github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250423092132-a35ace7371af // indirect github.com/lightningnetwork/lnd/cert v1.2.2 // indirect github.com/lightningnetwork/lnd/clock v1.1.1 // indirect github.com/lightningnetwork/lnd/fn/v2 v2.0.8 // indirect github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect - github.com/lightningnetwork/lnd/kvdb v1.4.15 // indirect + github.com/lightningnetwork/lnd/kvdb v1.4.16 // indirect github.com/lightningnetwork/lnd/queue v1.1.1 // indirect github.com/lightningnetwork/lnd/sqldb v1.0.9 // indirect github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect diff --git a/docs/examples/basic-price-oracle/go.sum b/docs/examples/basic-price-oracle/go.sum index 330d3395f..0c8b266ec 100644 --- a/docs/examples/basic-price-oracle/go.sum +++ b/docs/examples/basic-price-oracle/go.sum @@ -438,8 +438,8 @@ github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQ github.com/lightninglabs/lightning-node-connect v0.2.5-alpha h1:ZRVChwczFXK0CEbxOCWwUA6TIZvrkE0APd1T3WjFAwg= github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.2 h1:Er1miPZD2XZwcfE4xoS5AILqP1mj7kqnhbBSxW9BDxY= github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.2/go.mod h1:antQGRDRJiuyQF6l+k6NECCSImgCpwaZapATth2Chv4= -github.com/lightninglabs/lndclient v0.19.0-3 h1:PGGlDaz8x1dXGowDfAWhbuDqXTKNaJyb7SOTrRdG1es= -github.com/lightninglabs/lndclient v0.19.0-3/go.mod h1:5YMrFx00NvcmUHGZRxT4Qw/gOfR5x50/ReJmJ6w0yVk= +github.com/lightninglabs/lndclient v0.19.0-4 h1:U+koisg716/i51kf5ENI5+9a1joXcPXeJYl3q0s4/co= +github.com/lightninglabs/lndclient v0.19.0-4/go.mod h1:LP3FM3JGBdvOX8Lum9x1r7q54oiftoqaq4EYhtpp/fk= github.com/lightninglabs/neutrino v0.16.1 h1:5Kz4ToxncEVkpKC6fwUjXKtFKJhuxlG3sBB3MdJTJjs= github.com/lightninglabs/neutrino v0.16.1/go.mod h1:L+5UAccpUdyM7yDgmQySgixf7xmwBgJtOfs/IP26jCs= github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3shmlu5hIQ798g= @@ -448,8 +448,8 @@ github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb h1:yfM05S8DXKhuCBp5qSMZdtSwvJ+GFzl94KbXMNB1JDY= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= -github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250417120008-a304be6bad91 h1:LzLA7+J/fP1VrK4BcyAt86cg4/bkfY38gYRBJoy109o= -github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250417120008-a304be6bad91/go.mod h1:v4Y0gLAIqqxY83J+4HilQHIiScIy2ok2GSuBFtoc1zc= +github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250423092132-a35ace7371af h1:+t8N7kmI7YVu7Hzv8pPiMVCTjnSRi/qOxbAkXa5rn+0= +github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250423092132-a35ace7371af/go.mod h1:nCkZ6G6twxDKn31117M0BNfN5JSAmJVAHNTwYrn31BQ= github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI= github.com/lightningnetwork/lnd/cert v1.2.2/go.mod h1:jQmFn/Ez4zhDgq2hnYSw8r35bqGVxViXhX6Cd7HXM6U= github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= @@ -458,8 +458,8 @@ github.com/lightningnetwork/lnd/fn/v2 v2.0.8 h1:r2SLz7gZYQPVc3IZhU82M66guz3Zk2oY github.com/lightningnetwork/lnd/fn/v2 v2.0.8/go.mod h1:TOzwrhjB/Azw1V7aa8t21ufcQmdsQOQMDtxVOQWNl8s= github.com/lightningnetwork/lnd/healthcheck v1.2.6 h1:1sWhqr93GdkWy4+6U7JxBfcyZIE78MhIHTJZfPx7qqI= github.com/lightningnetwork/lnd/healthcheck v1.2.6/go.mod h1:Mu02um4CWY/zdTOvFje7WJgJcHyX2zq/FG3MhOAiGaQ= -github.com/lightningnetwork/lnd/kvdb v1.4.15 h1:3eN6uGcubvGB5itPp1D0D4uEEkIMYht3w0LDnqLzAWI= -github.com/lightningnetwork/lnd/kvdb v1.4.15/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= +github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p3HX1xtUdbDI= +github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= github.com/lightningnetwork/lnd/sqldb v1.0.9 h1:7OHi+Hui823mB/U9NzCdlZTAGSVdDCbjp33+6d/Q+G0= diff --git a/go.mod b/go.mod index 61d6821cf..2aeced3f2 100644 --- a/go.mod +++ b/go.mod @@ -27,9 +27,9 @@ require ( github.com/lib/pq v1.10.9 github.com/lightninglabs/aperture v0.3.8-beta github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.2 - github.com/lightninglabs/lndclient v0.19.0-3 + github.com/lightninglabs/lndclient v0.19.0-4 github.com/lightninglabs/neutrino/cache v1.1.2 - github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250417120008-a304be6bad91 + github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250423092132-a35ace7371af github.com/lightningnetwork/lnd/cert v1.2.2 github.com/lightningnetwork/lnd/clock v1.1.1 github.com/lightningnetwork/lnd/fn/v2 v2.0.8 @@ -127,7 +127,7 @@ require ( github.com/lightninglabs/neutrino v0.16.1 // indirect github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb // indirect github.com/lightningnetwork/lnd/healthcheck v1.2.6 // indirect - github.com/lightningnetwork/lnd/kvdb v1.4.15 // indirect + github.com/lightningnetwork/lnd/kvdb v1.4.16 // indirect github.com/lightningnetwork/lnd/queue v1.1.1 // indirect github.com/lightningnetwork/lnd/sqldb v1.0.9 // indirect github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect diff --git a/go.sum b/go.sum index 488a66f8a..11f2834f3 100644 --- a/go.sum +++ b/go.sum @@ -492,8 +492,8 @@ github.com/lightninglabs/lightning-node-connect v0.2.5-alpha h1:ZRVChwczFXK0CEbx github.com/lightninglabs/lightning-node-connect v0.2.5-alpha/go.mod h1:A9Pof9fETkH+F67BnOmrBDThPKstqp73wlImWOZvTXQ= github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.2 h1:Er1miPZD2XZwcfE4xoS5AILqP1mj7kqnhbBSxW9BDxY= github.com/lightninglabs/lightning-node-connect/hashmailrpc v1.0.2/go.mod h1:antQGRDRJiuyQF6l+k6NECCSImgCpwaZapATth2Chv4= -github.com/lightninglabs/lndclient v0.19.0-3 h1:PGGlDaz8x1dXGowDfAWhbuDqXTKNaJyb7SOTrRdG1es= -github.com/lightninglabs/lndclient v0.19.0-3/go.mod h1:5YMrFx00NvcmUHGZRxT4Qw/gOfR5x50/ReJmJ6w0yVk= +github.com/lightninglabs/lndclient v0.19.0-4 h1:U+koisg716/i51kf5ENI5+9a1joXcPXeJYl3q0s4/co= +github.com/lightninglabs/lndclient v0.19.0-4/go.mod h1:LP3FM3JGBdvOX8Lum9x1r7q54oiftoqaq4EYhtpp/fk= github.com/lightninglabs/neutrino v0.16.1 h1:5Kz4ToxncEVkpKC6fwUjXKtFKJhuxlG3sBB3MdJTJjs= github.com/lightninglabs/neutrino v0.16.1/go.mod h1:L+5UAccpUdyM7yDgmQySgixf7xmwBgJtOfs/IP26jCs= github.com/lightninglabs/neutrino/cache v1.1.2 h1:C9DY/DAPaPxbFC+xNNEI/z1SJY9GS3shmlu5hIQ798g= @@ -502,8 +502,8 @@ github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display h1:Y2WiPkBS github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb h1:yfM05S8DXKhuCBp5qSMZdtSwvJ+GFzl94KbXMNB1JDY= github.com/lightningnetwork/lightning-onion v1.2.1-0.20240712235311-98bd56499dfb/go.mod h1:c0kvRShutpj3l6B9WtTsNTBUtjSmjZXbJd9ZBRQOSKI= -github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250417120008-a304be6bad91 h1:LzLA7+J/fP1VrK4BcyAt86cg4/bkfY38gYRBJoy109o= -github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250417120008-a304be6bad91/go.mod h1:v4Y0gLAIqqxY83J+4HilQHIiScIy2ok2GSuBFtoc1zc= +github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250423092132-a35ace7371af h1:+t8N7kmI7YVu7Hzv8pPiMVCTjnSRi/qOxbAkXa5rn+0= +github.com/lightningnetwork/lnd v0.19.0-beta.rc2.0.20250423092132-a35ace7371af/go.mod h1:nCkZ6G6twxDKn31117M0BNfN5JSAmJVAHNTwYrn31BQ= github.com/lightningnetwork/lnd/cert v1.2.2 h1:71YK6hogeJtxSxw2teq3eGeuy4rHGKcFf0d0Uy4qBjI= github.com/lightningnetwork/lnd/cert v1.2.2/go.mod h1:jQmFn/Ez4zhDgq2hnYSw8r35bqGVxViXhX6Cd7HXM6U= github.com/lightningnetwork/lnd/clock v1.1.1 h1:OfR3/zcJd2RhH0RU+zX/77c0ZiOnIMsDIBjgjWdZgA0= @@ -512,8 +512,8 @@ github.com/lightningnetwork/lnd/fn/v2 v2.0.8 h1:r2SLz7gZYQPVc3IZhU82M66guz3Zk2oY github.com/lightningnetwork/lnd/fn/v2 v2.0.8/go.mod h1:TOzwrhjB/Azw1V7aa8t21ufcQmdsQOQMDtxVOQWNl8s= github.com/lightningnetwork/lnd/healthcheck v1.2.6 h1:1sWhqr93GdkWy4+6U7JxBfcyZIE78MhIHTJZfPx7qqI= github.com/lightningnetwork/lnd/healthcheck v1.2.6/go.mod h1:Mu02um4CWY/zdTOvFje7WJgJcHyX2zq/FG3MhOAiGaQ= -github.com/lightningnetwork/lnd/kvdb v1.4.15 h1:3eN6uGcubvGB5itPp1D0D4uEEkIMYht3w0LDnqLzAWI= -github.com/lightningnetwork/lnd/kvdb v1.4.15/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= +github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p3HX1xtUdbDI= +github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= github.com/lightningnetwork/lnd/sqldb v1.0.9 h1:7OHi+Hui823mB/U9NzCdlZTAGSVdDCbjp33+6d/Q+G0= diff --git a/rfqmsg/custom_channel_data.go b/rfqmsg/custom_channel_data.go index cd4deb3e8..19d3e2a25 100644 --- a/rfqmsg/custom_channel_data.go +++ b/rfqmsg/custom_channel_data.go @@ -1,5 +1,12 @@ package rfqmsg +import ( + "strings" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" +) + // JsonAssetBalance is a struct that represents the balance of a single asset ID // within a channel. type JsonAssetBalance struct { @@ -37,12 +44,40 @@ type JsonAssetChannel struct { OutgoingHtlcs []JsonAssetTranche `json:"outgoing_htlcs"` IncomingHtlcs []JsonAssetTranche `json:"incoming_htlcs"` Capacity uint64 `json:"capacity"` + GroupKey string `json:"group_key,omitempty"` LocalBalance uint64 `json:"local_balance"` RemoteBalance uint64 `json:"remote_balance"` OutgoingHtlcBalance uint64 `json:"outgoing_htlc_balance"` IncomingHtlcBalance uint64 `json:"incoming_htlc_balance"` } +// HasAllAssetIDs checks if the OpenChannel contains all asset IDs in the +// provided set. It returns true if all asset IDs are present, false otherwise. +func (c *JsonAssetChannel) HasAllAssetIDs(ids fn.Set[asset.ID]) bool { + // There is a possibility that we're checking the asset ID from an HTLC + // that hasn't been materialized yet and could actually contain a group + // key x-coordinate. That should only be the case if there is a single + // asset ID. + if len(ids) == 1 && c.GroupKey != "" { + assetID := ids.ToSlice()[0] + if strings.Contains(c.GroupKey, assetID.String()) { + return true + } + } + + availableIDStrings := fn.NewSet(fn.Map( + c.FundingAssets, func(fundingAsset JsonAssetUtxo) string { + return fundingAsset.AssetGenesis.AssetID + }, + )...) + targetIDStrings := fn.NewSet(fn.Map( + ids.ToSlice(), func(id asset.ID) string { + return id.String() + }, + )...) + return targetIDStrings.Subset(availableIDStrings) +} + // JsonAssetChannelBalances is a struct that represents the balance information // of all assets within open and pending channels. type JsonAssetChannelBalances struct { diff --git a/server.go b/server.go index 96aec57cd..c26802d21 100644 --- a/server.go +++ b/server.go @@ -12,7 +12,6 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - "github.com/davecgh/go-spew/spew" proxy "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/address" @@ -34,6 +33,7 @@ import ( "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwallet" lnwl "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chancloser" @@ -997,16 +997,19 @@ func (s *Server) ChannelFinalized(pid funding.PendingChanID) error { // // NOTE: This method is part of the routing.TlvTrafficShaper interface. func (s *Server) ShouldHandleTraffic(cid lnwire.ShortChannelID, - fundingBlob lfn.Option[tlv.Blob]) (bool, error) { + fundingBlob, htlcBlob lfn.Option[tlv.Blob]) (bool, error) { - srvrLog.Debugf("HandleTraffic called (cid=%v, fundingBlob=%x)", cid, - fundingBlob.UnwrapOr(tlv.Blob{})) + srvrLog.Debugf("HandleTraffic called, cid=%v, fundingBlob=%v, "+ + "htlcBlob=%v", cid, lnutils.SpewLogClosure(fundingBlob), + lnutils.SpewLogClosure(htlcBlob)) if err := s.waitForReady(); err != nil { return false, err } - return s.cfg.AuxTrafficShaper.ShouldHandleTraffic(cid, fundingBlob) + return s.cfg.AuxTrafficShaper.ShouldHandleTraffic( + cid, fundingBlob, htlcBlob, + ) } // PaymentBandwidth returns the available bandwidth for a custom channel decided @@ -1016,20 +1019,23 @@ func (s *Server) ShouldHandleTraffic(cid lnwire.ShortChannelID, // called first. // // NOTE: This method is part of the routing.TlvTrafficShaper interface. -func (s *Server) PaymentBandwidth(htlcBlob, commitmentBlob lfn.Option[tlv.Blob], - linkBandwidth, htlcAmt lnwire.MilliSatoshi, +func (s *Server) PaymentBandwidth(fundingBlob, htlcBlob, + commitmentBlob lfn.Option[tlv.Blob], linkBandwidth, + htlcAmt lnwire.MilliSatoshi, htlcView lnwallet.AuxHtlcView) (lnwire.MilliSatoshi, error) { - srvrLog.Debugf("PaymentBandwidth called, htlcBlob=%v, "+ - "commitmentBlob=%v", spew.Sdump(htlcBlob), - spew.Sdump(commitmentBlob)) + srvrLog.Debugf("PaymentBandwidth called, fundingBlob=%v, htlcBlob=%v, "+ + "commitmentBlob=%v", lnutils.SpewLogClosure(fundingBlob), + lnutils.SpewLogClosure(htlcBlob), + lnutils.SpewLogClosure(commitmentBlob)) if err := s.waitForReady(); err != nil { return 0, err } return s.cfg.AuxTrafficShaper.PaymentBandwidth( - htlcBlob, commitmentBlob, linkBandwidth, htlcAmt, htlcView, + fundingBlob, htlcBlob, commitmentBlob, linkBandwidth, htlcAmt, + htlcView, ) } @@ -1043,7 +1049,8 @@ func (s *Server) ProduceHtlcExtraData(totalAmount lnwire.MilliSatoshi, lnwire.CustomRecords, error) { srvrLog.Debugf("ProduceHtlcExtraData called, totalAmount=%d, "+ - "htlcBlob=%v", totalAmount, spew.Sdump(htlcCustomRecords)) + "htlcBlob=%v", totalAmount, + lnutils.SpewLogClosure(htlcCustomRecords)) if err := s.waitForReady(); err != nil { return 0, nil, err @@ -1074,7 +1081,8 @@ func (s *Server) AuxCloseOutputs( desc chancloser.AuxCloseDesc) (lfn.Option[chancloser.AuxCloseOutputs], error) { - srvrLog.Tracef("AuxCloseOutputs called, desc=%v", spew.Sdump(desc)) + srvrLog.Tracef("AuxCloseOutputs called, desc=%v", + lnutils.SpewLogClosure(desc)) if err := s.waitForReady(); err != nil { return lfn.None[chancloser.AuxCloseOutputs](), err @@ -1091,7 +1099,8 @@ func (s *Server) ShutdownBlob( req chancloser.AuxShutdownReq) (lfn.Option[lnwire.CustomRecords], error) { - srvrLog.Tracef("ShutdownBlob called, req=%v", spew.Sdump(req)) + srvrLog.Tracef("ShutdownBlob called, req=%v", + lnutils.SpewLogClosure(req)) if err := s.waitForReady(); err != nil { return lfn.None[lnwire.CustomRecords](), err @@ -1109,7 +1118,7 @@ func (s *Server) FinalizeClose(desc chancloser.AuxCloseDesc, closeTx *wire.MsgTx) error { srvrLog.Tracef("FinalizeClose called, desc=%v, closeTx=%v", - spew.Sdump(desc), spew.Sdump(closeTx)) + lnutils.SpewLogClosure(desc), lnutils.SpewLogClosure(closeTx)) if err := s.waitForReady(); err != nil { return err @@ -1123,7 +1132,8 @@ func (s *Server) FinalizeClose(desc chancloser.AuxCloseDesc, // // NOTE: This method is part of the lnwallet.AuxContractResolver interface. func (s *Server) ResolveContract(req lnwl.ResolutionReq) lfn.Result[tlv.Blob] { - srvrLog.Tracef("ResolveContract called, req=%v", spew.Sdump(req)) + srvrLog.Tracef("ResolveContract called, req=%v", + lnutils.SpewLogClosure(req)) if err := s.waitForReady(); err != nil { return lfn.Err[tlv.Blob](err) @@ -1141,7 +1151,7 @@ func (s *Server) DeriveSweepAddr(inputs []input.Input, change lnwl.AddrWithKey) lfn.Result[sweep.SweepOutput] { srvrLog.Tracef("DeriveSweepAddr called, inputs=%v, change=%v", - spew.Sdump(inputs), spew.Sdump(change)) + lnutils.SpewLogClosure(inputs), lnutils.SpewLogClosure(change)) if err := s.waitForReady(); err != nil { return lfn.Err[sweep.SweepOutput](err) @@ -1158,7 +1168,7 @@ func (s *Server) ExtraBudgetForInputs( inputs []input.Input) lfn.Result[btcutil.Amount] { srvrLog.Tracef("ExtraBudgetForInputs called, inputs=%v", - spew.Sdump(inputs)) + lnutils.SpewLogClosure(inputs)) if err := s.waitForReady(); err != nil { return lfn.Err[btcutil.Amount](err) @@ -1176,8 +1186,9 @@ func (s *Server) NotifyBroadcast(req *sweep.BumpRequest, outpointToTxIndex map[wire.OutPoint]int) error { srvrLog.Tracef("NotifyBroadcast called, req=%v, tx=%v, fee=%v, "+ - "out_index=%v", spew.Sdump(req), spew.Sdump(tx), fee, - spew.Sdump(outpointToTxIndex)) + "out_index=%v", lnutils.SpewLogClosure(req), + lnutils.SpewLogClosure(tx), fee, + lnutils.SpewLogClosure(outpointToTxIndex)) if err := s.waitForReady(); err != nil { return err diff --git a/tapcfg/server.go b/tapcfg/server.go index ddbc753c0..4970f49a9 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -487,6 +487,7 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, ChainParams: &tapChainParams, InvoiceHtlcModifier: lndInvoicesClient, RfqManager: rfqManager, + LightningClient: lndServices.Client, }, ) auxChanCloser := tapchannel.NewAuxChanCloser( diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index b323b8a21..52d126aaa 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -576,7 +576,8 @@ func newCommitBlobAndLeaves(pendingFunding *pendingAssetFunding, // both sides are able to construct the funding output, and will be able to // store the appropriate funding blobs. func (p *pendingAssetFunding) toAuxFundingDesc(req *bindFundingReq, - decimalDisplay uint8) (*lnwallet.AuxFundingDesc, error) { + decimalDisplay uint8, + groupKey *btcec.PublicKey) (*lnwallet.AuxFundingDesc, error) { // First, we'll map all the assets into asset outputs that'll be stored // in the open channel struct on the lnd side. @@ -584,7 +585,9 @@ func (p *pendingAssetFunding) toAuxFundingDesc(req *bindFundingReq, // With all the outputs assembled, we'll now map that to the open // channel wrapper that'll go in the set of TLV blobs. - openChanDesc := cmsg.NewOpenChannel(assetOutputs, decimalDisplay) + openChanDesc := cmsg.NewOpenChannel( + assetOutputs, decimalDisplay, groupKey, + ) // Now we'll encode the 3 TLV blobs that lnd will store: the main one // for the funding details, and then the blobs for the local and remote @@ -1933,8 +1936,20 @@ func (f *FundingController) chanFunder() { continue } + groupKey, err := f.fundingAssetGroupKey( + ctxc, fundingFlow.assetOutputs(), + ) + if err != nil { + fErr := fmt.Errorf("unable to determine group "+ + "key: %w", err) + f.cfg.ErrReporter.ReportError( + ctxc, fundingFlow.peerPub, pid, fErr, + ) + continue + } + fundingDesc, err := fundingFlow.toAuxFundingDesc( - req, decimalDisplay, + req, decimalDisplay, groupKey, ) if err != nil { fErr := fmt.Errorf("unable to create aux "+ @@ -2029,6 +2044,65 @@ func (f *FundingController) fundingAssetDecimalDisplay(ctx context.Context, return decimalDisplay, nil } +// fundingAssetGroupKey determines the group key of the funding asset(s). If no +// group key was used to fund the channel, then nil is returned. +func (f *FundingController) fundingAssetGroupKey(ctx context.Context, + assetOutputs []*cmsg.AssetOutput) (*btcec.PublicKey, error) { + + // We now check the group key of each funding asset, to make sure we + // know the meta information for each asset. And we also verify that + // each asset tranche has the same group key. + var groupKey *btcec.PublicKey + for _, a := range assetOutputs { + info, err := f.cfg.AssetSyncer.QueryAssetInfo( + ctx, a.AssetID.Val, + ) + switch { + // If the asset isn't a grouped asset (or we don't know the + // asset), then we just continue. + case errors.Is(err, address.ErrAssetGroupUnknown): + continue + + case err != nil: + return nil, fmt.Errorf("unable to fetch group info: %w", + err) + } + + switch { + // We haven't set the group key before and have found one now, + // perfect. Let's assume that's our group key we'll use. + case groupKey == nil && info.GroupKey != nil: + groupKey = &info.GroupKey.GroupPubKey + + // If we already have a group key, then we need to verify that + // the group key of this asset matches the one we already have. + case groupKey != nil && info.GroupKey != nil: + if !groupKey.IsEqual(&info.GroupKey.GroupPubKey) { + return nil, fmt.Errorf("group key mismatch: "+ + "expected %x, got %x", + groupKey.SerializeCompressed(), + info.GroupPubKey.SerializeCompressed()) + } + + // If a previous asset resulted in a group key, every following + // one must also result in the same one. If we can't find one + // now, it means we either don't know about the asset (not + // synced) or it's not a grouped asset. + case groupKey != nil && info.GroupKey == nil: + return nil, fmt.Errorf("group key mismatch: "+ + "expected %x, got nil", + groupKey.SerializeCompressed()) + + // If we don't have a group key yet, and the asset isn't a + // grouped asset, then we just continue. + case groupKey == nil && info.GroupKey == nil: + continue + } + } + + return groupKey, nil +} + // channelAcceptor is a callback that's called by the lnd client when a new // channel is proposed. This function is responsible for deciding whether to // accept the channel based on the channel parameters, and to also set some diff --git a/tapchannel/aux_invoice_manager.go b/tapchannel/aux_invoice_manager.go index 92be86be7..5bea9c1dc 100644 --- a/tapchannel/aux_invoice_manager.go +++ b/tapchannel/aux_invoice_manager.go @@ -2,6 +2,7 @@ package tapchannel import ( "context" + "encoding/json" "fmt" "sync" @@ -13,7 +14,9 @@ import ( "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightninglabs/taproot-assets/rfqmsg" "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" ) @@ -77,6 +80,10 @@ type InvoiceManagerConfig struct { // accepted quotes for determining the incoming value of invoice related // HTLCs. RfqManager RfqManager + + // LndClient is the lnd client that will be used to interact with the + // lnd node. + LightningClient lndclient.LightningClient } // AuxInvoiceManager is a Taproot Asset auxiliary invoice manager that can be @@ -87,6 +94,12 @@ type AuxInvoiceManager struct { cfg *InvoiceManagerConfig + // channelFundingCache is a cache used to store the channel funding + // information for the channels that are used to receive assets. The map + // is keyed by the main channel ID, and the value is the asset channel + // funding information. + channelFundingCache lnutils.SyncMap[uint64, rfqmsg.JsonAssetChannel] + // ContextGuard provides a wait group and main quit channel that can be // used to create guarded contexts. *fn.ContextGuard @@ -206,7 +219,7 @@ func (s *AuxInvoiceManager) handleInvoiceAccept(ctx context.Context, } // We now run some validation checks on the asset HTLC. - err = s.validateAssetHTLC(ctx, htlc) + err = s.validateAssetHTLC(ctx, htlc, resp.CircuitKey) if err != nil { log.Errorf("Failed to validate asset HTLC: %v", err) @@ -268,11 +281,11 @@ func (s *AuxInvoiceManager) handleInvoiceAccept(ctx context.Context, return resp, nil } -// identifierFromQuote retrieves the quote by looking up the rfq manager's maps -// of accepted quotes based on the passed rfq ID. If there's a match, the asset -// specifier is returned. -func (s *AuxInvoiceManager) identifierFromQuote( - rfqID rfqmsg.ID) (asset.Specifier, error) { +// identifierAndPeerFromQuote retrieves the quote by looking up the rfq +// manager's maps of accepted quotes based on the passed rfq ID. If there's a +// match, the asset specifier and peer are returned. +func (s *AuxInvoiceManager) identifierAndPeerFromQuote( + rfqID rfqmsg.ID) (asset.Specifier, route.Vertex, error) { acceptedBuyQuotes := s.cfg.RfqManager.PeerAcceptedBuyQuotes() acceptedSellQuotes := s.cfg.RfqManager.LocalAcceptedSellQuotes() @@ -280,23 +293,28 @@ func (s *AuxInvoiceManager) identifierFromQuote( buyQuote, isBuy := acceptedBuyQuotes[rfqID.Scid()] sellQuote, isSell := acceptedSellQuotes[rfqID.Scid()] - var specifier asset.Specifier + var ( + specifier asset.Specifier + peer route.Vertex + ) switch { case isBuy: specifier = buyQuote.Request.AssetSpecifier + peer = buyQuote.Peer case isSell: specifier = sellQuote.Request.AssetSpecifier + peer = sellQuote.Peer } err := specifier.AssertNotEmpty() if err != nil { - return specifier, fmt.Errorf("rfqID does not match any "+ + return specifier, peer, fmt.Errorf("rfqID does not match any "+ "accepted buy or sell quote: %v", err) } - return specifier, nil + return specifier, peer, nil } // priceFromQuote retrieves the price from the accepted quote for the given RFQ @@ -390,12 +408,12 @@ func isAssetInvoice(invoice *lnrpc.Invoice, rfqLookup RfqLookup) bool { // validateAssetHTLC runs a couple of checks on the provided asset HTLC. func (s *AuxInvoiceManager) validateAssetHTLC(ctx context.Context, - htlc *rfqmsg.Htlc) error { + htlc *rfqmsg.Htlc, circuitKey invoices.CircuitKey) error { rfqID := htlc.RfqID.ValOpt().UnsafeFromSome() // Retrieve the asset identifier from the RFQ quote. - identifier, err := s.identifierFromQuote(rfqID) + identifier, peer, err := s.identifierAndPeerFromQuote(rfqID) if err != nil { return fmt.Errorf("could not extract assetID from "+ "quote: %v", err) @@ -403,6 +421,7 @@ func (s *AuxInvoiceManager) validateAssetHTLC(ctx context.Context, // Check for each of the asset balances of the HTLC that the identifier // matches that of the RFQ quote. + assetIDs := fn.NewSet[asset.ID]() for _, v := range htlc.Balances() { match, err := s.cfg.RfqManager.AssetMatchesSpecifier( ctx, identifier, v.AssetID.Val, @@ -415,11 +434,81 @@ func (s *AuxInvoiceManager) validateAssetHTLC(ctx context.Context, return fmt.Errorf("asset ID %s does not match %s", v.AssetID.Val.String(), identifier.String()) } + + assetIDs.Add(v.AssetID.Val) + } + + assetData, err := s.fetchChannelAssetData(ctx, circuitKey.ChanID, peer) + if err != nil { + return fmt.Errorf("unable to fetch channel asset data: %w", err) + } + + if !assetData.HasAllAssetIDs(assetIDs) { + return fmt.Errorf("channel %d does not have all asset IDs "+ + "required for HTLC settlement", + circuitKey.ChanID) } return nil } +// fetchChannelAssetData retrieves the asset channel data for the provided +// channel ID. If the cache doesn't contain the data, it is queried from the +// backing lnd node. +func (s *AuxInvoiceManager) fetchChannelAssetData(ctx context.Context, + chanID lnwire.ShortChannelID, + peer route.Vertex) (*rfqmsg.JsonAssetChannel, error) { + + // Do we have the information cached? Great, no lookup necessary. We + // don't need to worry about cache invalidation because the funding + // information remains constant for the lifetime of the channel. + cachedAssetData, ok := s.channelFundingCache.Load(chanID.ToUint64()) + if ok { + return &cachedAssetData, nil + } + + // We also need to validate that the HTLC is actually the correct asset + // and arrived through the correct asset channel. + channels, err := s.cfg.LightningClient.ListChannels( + ctx, true, false, lndclient.WithPeer(peer[:]), + ) + if err != nil { + return nil, fmt.Errorf("unable to list channels: %w", err) + } + + var inboundChannel *lndclient.ChannelInfo + for _, channel := range channels { + if channel.ChannelID == chanID.ToUint64() { + inboundChannel = &channel + break + } + } + + if inboundChannel == nil { + return nil, fmt.Errorf("unable to find channel with short "+ + "channel ID %d", chanID.ToUint64()) + } + + if len(inboundChannel.CustomChannelData) == 0 { + return nil, fmt.Errorf("channel %d does not have custom "+ + "channel data, can't accept asset HTLC over non-asset "+ + "channel", inboundChannel.ChannelID) + } + + var assetData rfqmsg.JsonAssetChannel + err = json.Unmarshal(inboundChannel.CustomChannelData, &assetData) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal channel asset "+ + "data: %w", err) + } + + // We cache the asset data for the channel so we don't have to look it + // up again. + s.channelFundingCache.Store(chanID.ToUint64(), assetData) + + return &assetData, nil +} + // Stop signals for an aux invoice manager to gracefully exit. func (s *AuxInvoiceManager) Stop() error { var stopErr error diff --git a/tapchannel/aux_invoice_manager_test.go b/tapchannel/aux_invoice_manager_test.go index e04132bc6..415f31cc4 100644 --- a/tapchannel/aux_invoice_manager_test.go +++ b/tapchannel/aux_invoice_manager_test.go @@ -1,8 +1,10 @@ package tapchannel import ( + "bytes" "context" "crypto/sha256" + "encoding/json" "fmt" "math/big" "testing" @@ -12,9 +14,11 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/rfq" "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" @@ -298,7 +302,9 @@ func (m *mockHtlcModifierProperty) HtlcModifier(ctx context.Context, } } else { if assetValueMsat != res.AmtPaid { - m.t.Errorf("unexpected final asset value") + m.t.Errorf("unexpected final asset value, "+ + "wanted %d, got %d", assetValueMsat, + res.AmtPaid) } } } @@ -309,9 +315,30 @@ func (m *mockHtlcModifierProperty) HtlcModifier(ctx context.Context, return nil } +type mockLndClient struct { + lndclient.LightningClient + + channels []lndclient.ChannelInfo +} + +// ListChannels retrieves all channels of the backing lnd node. +func (m *mockLndClient) ListChannels(_ context.Context, _, _ bool, + _ ...lndclient.ListChannelsOption) ([]lndclient.ChannelInfo, error) { + + return m.channels, nil +} + // TestAuxInvoiceManager tests that the htlc modifications of the aux invoice // manager align with our expectations. func TestAuxInvoiceManager(t *testing.T) { + randCircuitKey := func() invoices.CircuitKey { + return invoices.CircuitKey{ + ChanID: lnwire.NewShortChanIDFromInt( + test.RandInt[uint64](), + ), + } + } + testCases := []struct { name string buyQuotes rfq.BuyAcceptMap @@ -324,6 +351,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "non asset invoice", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{}, ExitHtlcAmt: 1234, }, @@ -338,6 +366,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "non asset routing hints", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testNonAssetHints(), ValueMsat: 1_000_000, @@ -360,6 +389,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "asset invoice, no custom records", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testRouteHints(), PaymentAddr: []byte{1, 1, 1}, @@ -382,6 +412,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "asset invoice, custom records", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testRouteHints(), ValueMsat: 3_000_000, @@ -417,6 +448,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "asset invoice, not enough amt", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testRouteHints(), ValueMsat: 10_000_000, @@ -453,6 +485,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "btc invoice, custom records", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ ValueMsat: 10_000_000, PaymentAddr: []byte{1, 1, 1}, @@ -477,6 +510,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "asset invoice, wrong asset htlc", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testRouteHints(), ValueMsat: 3_000_000, @@ -513,6 +547,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "asset invoice, group key rfq", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testRouteHints(), ValueMsat: 20_000_000, @@ -557,6 +592,7 @@ func TestAuxInvoiceManager(t *testing.T) { name: "asset invoice, group key rfq, bad htlc", requests: []lndclient.InvoiceHtlcModifyRequest{ { + CircuitKey: randCircuitKey(), Invoice: &lnrpc.Invoice{ RouteHints: testRouteHints(), ValueMsat: 20_000_000, @@ -604,11 +640,17 @@ func TestAuxInvoiceManager(t *testing.T) { t.Logf("Running AuxInvoiceManager test case: %v", testCase.name) + channels, err := genChannelsFromRequests(testCase.requests) + require.NoError(t, err) + // Instantiate mock rfq manager. mockRfq := &mockRfqManager{ peerBuyQuotes: testCase.buyQuotes, localSellQuotes: testCase.sellQuotes, } + mockLnd := &mockLndClient{ + channels: channels, + } done := make(chan bool) @@ -626,10 +668,11 @@ func TestAuxInvoiceManager(t *testing.T) { ChainParams: testChainParams, InvoiceHtlcModifier: mockModifier, RfqManager: mockRfq, + LightningClient: mockLnd, }, ) - err := manager.Start() + err = manager.Start() require.NoError(t, err) // If the manager is not done processing the htlc modification @@ -777,7 +820,13 @@ func genHtlc(t *rapid.T, balance []*rfqmsg.AssetBalance, func genRequest(t *rapid.T) (lndclient.InvoiceHtlcModifyRequest, uint64, asset.ID, rfqmsg.ID) { - request := lndclient.InvoiceHtlcModifyRequest{} + request := lndclient.InvoiceHtlcModifyRequest{ + CircuitKey: invoices.CircuitKey{ + ChanID: lnwire.NewShortChanIDFromInt( + rapid.Uint64().Draw(t, "chan_id"), + ), + }, + } rfqID := genRandomRfqID(t) @@ -800,13 +849,12 @@ func genRequest(t *rapid.T) (lndclient.InvoiceHtlcModifyRequest, uint64, // genRequests generates a random array of requests to be processed by the // AuxInvoiceManager. It also returns the rfq map with the related rfq quotes. func genRequests(t *rapid.T) ([]lndclient.InvoiceHtlcModifyRequest, - rfq.BuyAcceptMap) { + rfq.BuyAcceptMap, []lndclient.ChannelInfo) { rfqMap := rfq.BuyAcceptMap{} numRequests := rapid.IntRange(1, 5).Draw(t, "requestsLen") - requests := make([]lndclient.InvoiceHtlcModifyRequest, 0) - + var requests []lndclient.InvoiceHtlcModifyRequest for range numRequests { req, numAssets, assetID, scid := genRequest(t) requests = append(requests, req) @@ -819,7 +867,58 @@ func genRequests(t *rapid.T) ([]lndclient.InvoiceHtlcModifyRequest, genBuyQuotes(t, rfqMap, numAssets, quoteAmt, assetID, scid) } - return requests, rfqMap + channels, err := genChannelsFromRequests(requests) + require.NoError(t, err) + + return requests, rfqMap, channels +} + +// genChannelsFromRequests generates a list of channel info instances +// based on the provided requests. +func genChannelsFromRequests( + r []lndclient.InvoiceHtlcModifyRequest) ([]lndclient.ChannelInfo, + error) { + + channels := make([]lndclient.ChannelInfo, len(r)) + for i, req := range r { + var ( + buf bytes.Buffer + htlc rfqmsg.Htlc + jsonAssetChan rfqmsg.JsonAssetChannel + ) + err := req.WireCustomRecords.SerializeTo(&buf) + if err != nil { + return nil, err + } + + err = htlc.Decode(&buf) + if err != nil { + return nil, err + } + + jsonAssetChan.FundingAssets = make( + []rfqmsg.JsonAssetUtxo, len(htlc.Balances()), + ) + for idx, balance := range htlc.Balances() { + fundingAsset := &jsonAssetChan.FundingAssets[idx] + fundingAssetGen := &fundingAsset.AssetGenesis + fundingAssetGen.AssetID = balance.AssetID.Val.String() + + jsonAssetChan.GroupKey += balance.AssetID.Val.String() + } + + jsonChan, err := json.Marshal(jsonAssetChan) + if err != nil { + return nil, err + } + + channels[i] = lndclient.ChannelInfo{ + ChannelID: req.CircuitKey.ChanID.ToUint64(), + CustomChannelData: jsonChan, + } + } + + return channels, nil } // genRandomVertex generates a route.Vertex instance filled with random bytes. @@ -898,11 +997,14 @@ func genBuyQuotes(t *rapid.T, rfqMap rfq.BuyAcceptMap, units, amtMsat uint64, // testInvoiceManager creates an array of requests to be processed by the // AuxInvoiceManager. Uses the enhanced HtlcModifierMockProperty instance. func testInvoiceManager(t *rapid.T) { - requests, rfqMap := genRequests(t) + requests, rfqMap, channels := genRequests(t) mockRfq := &mockRfqManager{ peerBuyQuotes: rfqMap, } + mockLnd := &mockLndClient{ + channels: channels, + } done := make(chan bool) @@ -913,13 +1015,12 @@ func testInvoiceManager(t *rapid.T) { t: t, } - manager := NewAuxInvoiceManager( - &InvoiceManagerConfig{ - ChainParams: testChainParams, - InvoiceHtlcModifier: mockModifier, - RfqManager: mockRfq, - }, - ) + manager := NewAuxInvoiceManager(&InvoiceManagerConfig{ + ChainParams: testChainParams, + InvoiceHtlcModifier: mockModifier, + RfqManager: mockRfq, + LightningClient: mockLnd, + }) err := manager.Start() require.NoError(t, err) diff --git a/tapchannel/aux_traffic_shaper.go b/tapchannel/aux_traffic_shaper.go index b40728132..361a9835d 100644 --- a/tapchannel/aux_traffic_shaper.go +++ b/tapchannel/aux_traffic_shaper.go @@ -81,27 +81,30 @@ func (s *AuxTrafficShaper) Stop() error { // it is handled by the traffic shaper, then the normal bandwidth calculation // can be skipped and the bandwidth returned by PaymentBandwidth should be used // instead. -func (s *AuxTrafficShaper) ShouldHandleTraffic(_ lnwire.ShortChannelID, - fundingBlob lfn.Option[tlv.Blob]) (bool, error) { - - // If there is no auxiliary blob in the channel, it's not a custom - // channel, and we don't need to handle it. - if fundingBlob.IsNone() { - log.Tracef("No aux funding blob set, not handling traffic") +func (s *AuxTrafficShaper) ShouldHandleTraffic(cid lnwire.ShortChannelID, + _, htlcBlob lfn.Option[tlv.Blob]) (bool, error) { + + // The rule here is simple: If the HTLC is an asset HTLC, we _need_ to + // handle the bandwidth. Because of non-strict forwarding in lnd, it + // could otherwise be the case that we forward an asset HTLC on a + // non-asset channel, which would be a problem. + htlcBytes := htlcBlob.UnwrapOr(nil) + if len(htlcBytes) == 0 { + log.Tracef("Empty HTLC blob, not handling traffic for %v", cid) return false, nil } - // If we can successfully decode the channel blob as a channel capacity - // information, we know that this is a custom channel. - err := lfn.MapOptionZ(fundingBlob, func(blob tlv.Blob) error { - _, err := cmsg.DecodeOpenChannel(blob) - return err - }) - if err != nil { - return false, err + // If there are no asset HTLC custom records, we don't need to do + // anything as this is a regular payment. + if !rfqmsg.HasAssetHTLCEntries(htlcBytes) { + log.Tracef("No asset HTLC custom records, not handling "+ + "traffic for %v", cid) + return false, nil } - // No error, so this is a custom channel, we'll want to decide. + // If this _is_ an asset HTLC, we definitely want to handle the + // bandwidth for this channel, so we can deny forwarding asset HTLCs + // over non-asset channels. return true, nil } @@ -110,60 +113,37 @@ func (s *AuxTrafficShaper) ShouldHandleTraffic(_ lnwire.ShortChannelID, // is no bandwidth available. To find out if a channel is a custom channel that // should be handled by the traffic shaper, the HandleTraffic method should be // called first. -func (s *AuxTrafficShaper) PaymentBandwidth(htlcBlob, +func (s *AuxTrafficShaper) PaymentBandwidth(fundingBlob, htlcBlob, commitmentBlob lfn.Option[tlv.Blob], linkBandwidth, htlcAmt lnwire.MilliSatoshi, htlcView lnwallet.AuxHtlcView) (lnwire.MilliSatoshi, error) { - // If the commitment or HTLC blob is not set, we don't have any - // information about the channel and cannot determine the available - // bandwidth from a taproot asset perspective. We return the link - // bandwidth as a fallback. - if commitmentBlob.IsNone() || htlcBlob.IsNone() { - log.Tracef("No commitment or HTLC blob set, returning link "+ - "bandwidth %v", linkBandwidth) - return linkBandwidth, nil - } - - commitmentBytes := commitmentBlob.UnsafeFromSome() - htlcBytes := htlcBlob.UnsafeFromSome() - - // Sometimes the blob is set but actually empty, in which case we also - // don't have any information about the channel. - if len(commitmentBytes) == 0 || len(htlcBytes) == 0 { - log.Tracef("Empty commitment or HTLC blob, returning link "+ - "bandwidth %v", linkBandwidth) + fundingBlobBytes := fundingBlob.UnwrapOr(nil) + htlcBytes := htlcBlob.UnwrapOr(nil) + commitmentBytes := commitmentBlob.UnwrapOr(nil) + + // If the HTLC is not an asset HTLC, we can just return the normal link + // bandwidth, as we don't need to do any special math. We shouldn't even + // get here in the first place, since the ShouldHandleTraffic function + // should return false in this case. + if len(htlcBytes) == 0 || !rfqmsg.HasAssetHTLCEntries(htlcBytes) { + log.Tracef("Empty HTLC blob or no asset HTLC custom records, "+ + "returning link bandwidth %v", linkBandwidth) return linkBandwidth, nil } - // If there are no asset HTLC custom records, we don't need to do - // anything as this is a regular payment. - if !rfqmsg.HasAssetHTLCEntries(htlcBytes) { - log.Tracef("No asset HTLC custom records, returning link "+ - "bandwidth %v", linkBandwidth) - return linkBandwidth, nil + // If this is an asset HTLC but the channel is not an asset channel, we + // MUST deny forwarding the HTLC. + if len(commitmentBytes) == 0 || len(fundingBlobBytes) == 0 { + log.Tracef("Empty commitment or funding blob, cannot forward" + + "asset HTLC over non-asset channel, returning 0 " + + "bandwidth") + return 0, nil } - // Get the minimum HTLC amount, which is just above dust. - minHtlcAmt := rfqmath.DefaultOnChainHtlcMSat - - // LND calls this hook twice. Once to see if the overall budget of the - // node is enough, and then during pathfinding to actually see if - // there's enough balance in the channel to make the payment attempt. - // - // When doing the overall balance check, we don't know what the actual - // htlcAmt is in satoshis, so a value of 0 will be passed here. Let's at - // least check if we can afford the min amount above dust. If the actual - // htlc amount ends up being greater when calling this method during - // pathfinding, we will still check it below. - - // If the passed htlcAmt is below dust, then assume the dust amount. At - // this point we know we are sending assets, so we cannot anchor them to - // dust amounts. Dust HTLCs are added to the fees and aren't - // materialized in an on-chain output, so we wouldn't have anything - // to anchor the asset commitment to. - if htlcAmt < minHtlcAmt { - htlcAmt = minHtlcAmt + fundingChan, err := cmsg.DecodeOpenChannel(fundingBlobBytes) + if err != nil { + return 0, fmt.Errorf("error decoding funding blob: %w", err) } commitment, err := cmsg.DecodeCommitment(commitmentBytes) @@ -176,6 +156,23 @@ func (s *AuxTrafficShaper) PaymentBandwidth(htlcBlob, return 0, fmt.Errorf("error decoding HTLC blob: %w", err) } + // Before we do any further checks, we actually need to make sure that + // the HTLC is compatible with this channel. Because of `lnd`'s + // non-strict forwarding, if there are multiple asset channels, the + // wrong one could be chosen if we signal there's bandwidth. So we need + // to tell `lnd` it can't use this channel if the assets aren't + // compatible. + htlcAssetIDs := fn.NewSet[asset.ID](fn.Map( + htlc.Balances(), func(b *rfqmsg.AssetBalance) asset.ID { + return b.AssetID.Val + })..., + ) + if !fundingChan.HasAllAssetIDs(htlcAssetIDs) { + log.Tracef("HTLC asset IDs %v not compatible with asset IDs "+ + "of channel, returning 0 bandwidth", htlcAssetIDs) + return 0, nil + } + // With the help of the latest HtlcView, let's calculate a more precise // local balance. This is useful in order to not forward HTLCs that may // never be settled. Other HTLCs that may also call into this method are @@ -196,6 +193,28 @@ func (s *AuxTrafficShaper) PaymentBandwidth(htlcBlob, return prettyPrintLocalView(*decodedView) })) + // Get the minimum HTLC amount, which is just above dust. + minHtlcAmt := rfqmath.DefaultOnChainHtlcMSat + + // LND calls this hook twice. Once to see if the overall budget of the + // node is enough, and then during pathfinding to actually see if + // there's enough balance in the channel to make the payment attempt. + // + // When doing the overall balance check, we don't know what the actual + // htlcAmt is in satoshis, so a value of 0 will be passed here. Let's at + // least check if we can afford the min amount above dust. If the actual + // htlc amount ends up being greater when calling this method during + // pathfinding, we will still check it below. + + // If the passed htlcAmt is below dust, then assume the dust amount. At + // this point we know we are sending assets, so we cannot anchor them to + // dust amounts. Dust HTLCs are added to the fees and aren't + // materialized in an on-chain output, so we wouldn't have anything + // to anchor the asset commitment to. + if htlcAmt < minHtlcAmt { + htlcAmt = minHtlcAmt + } + // If the HTLC carries asset units (keysend, forwarding), then there's // no need to do any RFQ related math. We can directly compare the asset // units of the HTLC with those in our local balance. diff --git a/tapchannelmsg/custom_channel_data.go b/tapchannelmsg/custom_channel_data.go index 2fc2e1bd2..fe7dba18d 100644 --- a/tapchannelmsg/custom_channel_data.go +++ b/tapchannelmsg/custom_channel_data.go @@ -7,6 +7,7 @@ import ( "fmt" "io" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/rfqmsg" "github.com/lightningnetwork/lnd/lnrpc" @@ -84,6 +85,14 @@ func (c *ChannelCustomData) AsJson() ([]byte, error) { IncomingHtlcBalance: c.LocalCommit.IncomingHtlcAssets.Val.Sum(), } + c.OpenChan.GroupKey.ValOpt().WhenSome(func(key *btcec.PublicKey) { + if key != nil { + resp.GroupKey = hex.EncodeToString( + key.SerializeCompressed(), + ) + } + }) + // First, we encode the funding state, which lists all assets committed // to the channel at the time of channel opening. for _, output := range c.OpenChan.Assets() { diff --git a/tapchannelmsg/custom_channel_data_test.go b/tapchannelmsg/custom_channel_data_test.go index bd23d189e..5a728c77a 100644 --- a/tapchannelmsg/custom_channel_data_test.go +++ b/tapchannelmsg/custom_channel_data_test.go @@ -39,7 +39,9 @@ func TestReadChannelCustomData(t *testing.T) { output3 := NewAssetOutput(assetID3, 3000, proof3) output4 := NewAssetOutput(assetID4, 4000, proof4) - fundingState := NewOpenChannel([]*AssetOutput{output1, output2}, 11) + fundingState := NewOpenChannel( + []*AssetOutput{output1, output2}, 11, nil, + ) commitState := NewCommitment( []*AssetOutput{output1}, []*AssetOutput{output2}, map[input.HtlcIndex][]*AssetOutput{ diff --git a/tapchannelmsg/records.go b/tapchannelmsg/records.go index 959649f62..0bb06e975 100644 --- a/tapchannelmsg/records.go +++ b/tapchannelmsg/records.go @@ -8,6 +8,7 @@ import ( "net/url" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/txscript" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" @@ -94,11 +95,23 @@ type OpenChannel struct { // this value needs to be the same for all assets. Otherwise, they would // not be fungible. DecimalDisplay tlv.RecordT[tlv.TlvType1, uint8] + + // GroupKey is the optional group key used to fund this channel. + GroupKey tlv.OptionalRecordT[tlv.TlvType2, *btcec.PublicKey] } // NewOpenChannel creates a new OpenChannel record with the given funded assets. -func NewOpenChannel(fundedAssets []*AssetOutput, - decimalDisplay uint8) *OpenChannel { +func NewOpenChannel(fundedAssets []*AssetOutput, decimalDisplay uint8, + groupKey *btcec.PublicKey) *OpenChannel { + + var optGroupRecord tlv.OptionalRecordT[tlv.TlvType2, *btcec.PublicKey] + if groupKey != nil { + optGroupRecord = tlv.SomeRecordT[tlv.TlvType2]( + tlv.NewPrimitiveRecord[tlv.TlvType2]( + groupKey, + ), + ) + } return &OpenChannel{ FundedAssets: tlv.NewRecordT[tlv.TlvType0]( @@ -109,6 +122,7 @@ func NewOpenChannel(fundedAssets []*AssetOutput, DecimalDisplay: tlv.NewPrimitiveRecord[tlv.TlvType1]( decimalDisplay, ), + GroupKey: optGroupRecord, } } @@ -118,17 +132,18 @@ func (o *OpenChannel) Assets() []*AssetOutput { return o.FundedAssets.Val.Outputs } -// records returns the records that make up the OpenChannel. -func (o *OpenChannel) records() []tlv.Record { - return []tlv.Record{ +// Encode serializes the OpenChannel to the given io.Writer. +func (o *OpenChannel) Encode(w io.Writer) error { + tlvRecords := []tlv.Record{ o.FundedAssets.Record(), o.DecimalDisplay.Record(), } -} -// Encode serializes the OpenChannel to the given io.Writer. -func (o *OpenChannel) Encode(w io.Writer) error { - tlvRecords := o.records() + o.GroupKey.WhenSome( + func(r tlv.RecordT[tlv.TlvType2, *btcec.PublicKey]) { + tlvRecords = append(tlvRecords, r.Record()) + }, + ) // Create the tlv stream. tlvStream, err := tlv.NewStream(tlvRecords...) @@ -141,13 +156,30 @@ func (o *OpenChannel) Encode(w io.Writer) error { // Decode deserializes the OpenChannel from the given io.Reader. func (o *OpenChannel) Decode(r io.Reader) error { + groupKey := o.GroupKey.Zero() + + tlvRecords := []tlv.Record{ + o.FundedAssets.Record(), + o.DecimalDisplay.Record(), + groupKey.Record(), + } + // Create the tlv stream. - tlvStream, err := tlv.NewStream(o.records()...) + tlvStream, err := tlv.NewStream(tlvRecords...) if err != nil { return err } - return tlvStream.Decode(r) + tlvs, err := tlvStream.DecodeWithParsedTypes(r) + if err != nil { + return err + } + + if _, ok := tlvs[groupKey.TlvType()]; ok { + o.GroupKey = tlv.SomeRecordT(groupKey) + } + + return nil } // Bytes returns the serialized OpenChannel record. @@ -157,6 +189,46 @@ func (o *OpenChannel) Bytes() []byte { return buf.Bytes() } +// HasAllAssetIDs checks if the OpenChannel contains all asset IDs in the +// provided set. It returns true if all asset IDs are present, false otherwise. +func (o *OpenChannel) HasAllAssetIDs(ids fn.Set[asset.ID]) bool { + // There is a possibility that we're checking the asset ID from an HTLC + // that hasn't been materialized yet and could actually contain a group + // key x-coordinate. That should only be the case if there is a single + // asset ID. + if len(ids) == 1 && o.GroupKey.IsSome() { + assetID := ids.ToSlice()[0] + groupKeyMatch := lfn.MapOptionZ( + o.GroupKey.ValOpt(), + func(groupKey *btcec.PublicKey) bool { + if groupKey == nil { + return false + } + + return bytes.Equal( + assetID[:], schnorr.SerializePubKey( + groupKey, + ), + ) + }, + ) + + // Only if we get a match do we short-circuit the explicit asset + // ID check. + if groupKeyMatch { + return true + } + } + + availableIDs := fn.NewSet(fn.Map( + o.Assets(), func(output *AssetOutput) asset.ID { + return output.AssetID.Val + }, + )...) + + return ids.Subset(availableIDs) +} + // DecodeOpenChannel deserializes an OpenChannel from the given blob. func DecodeOpenChannel(blob tlv.Blob) (*OpenChannel, error) { var o OpenChannel diff --git a/tapchannelmsg/records_test.go b/tapchannelmsg/records_test.go index 888911f7c..1f6222b60 100644 --- a/tapchannelmsg/records_test.go +++ b/tapchannelmsg/records_test.go @@ -65,6 +65,7 @@ func TestOpenChannel(t *testing.T) { require.NoError(t, err) randProof, err := proof.Decode(proofBytes) require.NoError(t, err) + randGroupKey := test.RandPubKey(t) testCases := []struct { name string @@ -78,14 +79,22 @@ func TestOpenChannel(t *testing.T) { name: "channel with funded asset", channel: NewOpenChannel([]*AssetOutput{ NewAssetOutput([32]byte{1}, 1000, *randProof), - }, 0), + }, 0, nil), }, { name: "channel with multiple funded assets", channel: NewOpenChannel([]*AssetOutput{ NewAssetOutput([32]byte{1}, 1000, *randProof), NewAssetOutput([32]byte{2}, 2000, *randProof), - }, 11), + }, 11, nil), + }, + { + name: "channel with multiple funded assets and group " + + "key", + channel: NewOpenChannel([]*AssetOutput{ + NewAssetOutput([32]byte{1}, 1000, *randProof), + NewAssetOutput([32]byte{2}, 2000, *randProof), + }, 11, randGroupKey), }, } diff --git a/tapdb/assets_store.go b/tapdb/assets_store.go index 6b51261c7..99f0d8d85 100644 --- a/tapdb/assets_store.go +++ b/tapdb/assets_store.go @@ -2288,7 +2288,6 @@ func (a *AssetStore) queryCommitments(ctx context.Context, map[wire.OutPoint]*commitment.TapCommitment, ) for anchorPoint := range chainAnchorToAssets { - anchorPoint := anchorPoint anchorUTXO := anchorPoints[anchorPoint] anchoredAssets := chainAnchorToAssets[anchorPoint] anchoredAltLeaves := anchorAltLeaves[anchorPoint] diff --git a/tapdb/sqlc/queries/transfers.sql b/tapdb/sqlc/queries/transfers.sql index 0bcabaf5f..0d7757b85 100644 --- a/tapdb/sqlc/queries/transfers.sql +++ b/tapdb/sqlc/queries/transfers.sql @@ -137,8 +137,12 @@ RETURNING asset_id; -- name: ReAnchorPassiveAssets :exec UPDATE assets SET anchor_utxo_id = @new_anchor_utxo_id, + -- The following fields need to be the same fields we reset in + -- Asset.CopySpendTemplate. split_commitment_root_hash = NULL, - split_commitment_root_value = NULL + split_commitment_root_value = NULL, + lock_time = 0, + relative_lock_time = 0 WHERE asset_id = @asset_id; -- name: DeleteAssetWitnesses :exec diff --git a/tapdb/sqlc/transfers.sql.go b/tapdb/sqlc/transfers.sql.go index faeb2b7e0..5b1cb1c8a 100644 --- a/tapdb/sqlc/transfers.sql.go +++ b/tapdb/sqlc/transfers.sql.go @@ -687,8 +687,12 @@ func (q *Queries) QueryProofTransferAttempts(ctx context.Context, arg QueryProof const ReAnchorPassiveAssets = `-- name: ReAnchorPassiveAssets :exec UPDATE assets SET anchor_utxo_id = $1, + -- The following fields need to be the same fields we reset in + -- Asset.CopySpendTemplate. split_commitment_root_hash = NULL, - split_commitment_root_value = NULL + split_commitment_root_value = NULL, + lock_time = 0, + relative_lock_time = 0 WHERE asset_id = $2 ` diff --git a/tapsend/send.go b/tapsend/send.go index bb007e989..bcce1e362 100644 --- a/tapsend/send.go +++ b/tapsend/send.go @@ -1584,6 +1584,9 @@ func LogCommitment(prefix string, idx int, prefix, idx, tapCommitment.Version, merkleRoot[:], internalKey.SerializeCompressed(), pkScript, trimmedMerkleRoot) for _, a := range tapCommitment.CommittedAssets() { + var buf bytes.Buffer + _ = a.Encode(&buf) + groupKey := "" if a.GroupKey != nil { groupKey = hex.EncodeToString( @@ -1592,9 +1595,11 @@ func LogCommitment(prefix string, idx int, } log.Tracef("%v commitment asset_id=%v, script_key=%x, "+ "group_key=%v, amount=%d, version=%d, "+ - "split_commitment=%v", prefix, a.ID(), + "split_commitment=%v, encoded=%x", prefix, a.ID(), a.ScriptKey.PubKey.SerializeCompressed(), groupKey, - a.Amount, a.Version, a.SplitCommitmentRoot != nil) + a.Amount, a.Version, a.SplitCommitmentRoot != nil, + buf.Bytes()) + } }