diff --git a/README.md b/README.md index b2bc2a6..e9ce5ea 100644 --- a/README.md +++ b/README.md @@ -162,25 +162,28 @@ Parameters related to the initiator node's channels. | **max_htlc** | stat_range | Channels maximum HTLC | | **last_update_diff** | stat_range | Channels last update difference to the time of the request (seconds) | | **together** | range | Number of channels that the host node and initiator node have together | -| **incoming_fee_rates** | stat_range | Channels incoming fee rates | -| **outgoing_fee_rates** | stat_range | Channels outgoing fee rates | -| **incoming_base_fees** | stat_range | Channels incoming base fees | -| **outgoing_base_fees** | stat_range | Channels outgoing base fees | -| **outgoing_disabled** | stat_range | Number of outgoing disabled channels. The value type is float and should be between 0 and 1 | -| **incoming_disabled** | stat_range | Number of incoming disabled channels. The value type is float and should be between 0 and 1 | -| **incoming_inbound_fee_rates** | stat_range | Channels incoming inbound fee rates | -| **outgoing_inbound_fee_rates** | stat_range | Channels outgoing inbound fee rates | -| **incoming_inbound_base_fees** | stat_range | Channels incoming inbound base fees | -| **outgoing_inbound_base_fees** | stat_range | Channels outgoing inbound base fees | - -> [!Note] -> **Outgoing** refers to the channel value from the initiator's node side, **incoming** to the value corresponding to the initiator node's peer side. -> -> For instance, let's say Bob wants to open a channel with us and he already has one with Charlie. Bob has a base fee of 0 sats and Charlie has a base fee of 1 sat. In this case, the outgoing base fee is 0 sats (Bob's side) and the incoming base fee is 1 sat (Charlie's side). +| **fee_rates** | stat_range | Channels fee rates | +| **base_fees** | stat_range | Channels base fees | +| **disabled** | stat_range | Number of disabled channels. The value type is float and should be between 0 and 1 | +| **inbound_fee_rates** | stat_range | Channels inbound fee rates | +| **inbound_base_fees** | stat_range | Channels inbound base fees | +| **peers** | [Peers](#Peers) | Initiator node channels parameters on the peers' side | > [!Note] > **Inbound** fees were added in LND v0.18.0-beta and they represent fees for the movement of incoming funds. A positive value would discourage peers from routing to the channel and a negative value would incentivize them. +#### Peers + +Initiator node channels parameters on the peers' side. + +| Key | Type | Description | +| -- | -- | -- | +| **fee_rates** | stat_range | Channels fee rates | +| **base_fees** | stat_range | Channels base fees | +| **disabled** | stat_range | Number of disabled channels. The value type is float and should be between 0 and 1 | +| **inbound_fee_rates** | stat_range | Channels inbound fee rates | +| **inbound_base_fees** | stat_range | Channels inbound base fees | + #### Range A range may have a minimum value, a maximum value or both defined. All values are in **satoshis**. diff --git a/examples/advanced.yml b/examples/advanced.yml index 8b74811..165a2cb 100644 --- a/examples/advanced.yml +++ b/examples/advanced.yml @@ -9,11 +9,11 @@ policies: age: min: 52_560 channels: - outgoing_fee_rates: + fee_rates: operation: median min: 0 max: 200 - outgoing_base_fees: + base_fees: operation: range max: 1 block_height: diff --git a/examples/conditional.yml b/examples/conditional.yml index 099e6bc..419817a 100644 --- a/examples/conditional.yml +++ b/examples/conditional.yml @@ -21,6 +21,6 @@ policies: min: 100_000_000 node: channels: - outgoing_fee_rates: + fee_rates: operation: median max: 1000 diff --git a/examples/disabled_channels.yml b/examples/disabled_channels.yml index 84245fc..05242b0 100644 --- a/examples/disabled_channels.yml +++ b/examples/disabled_channels.yml @@ -2,9 +2,10 @@ policies: - node: channels: - incoming_disabled: + disabled: operation: mean + max: 0.05 + peers: + disabled: + operation: mean max: 0.1 - outgoing_disabled: - operation: mean - max: 0.05 diff --git a/examples/stat_range.yml b/examples/stat_range.yml index c4cfae3..016007a 100644 --- a/examples/stat_range.yml +++ b/examples/stat_range.yml @@ -2,7 +2,7 @@ policies: - node: channels: - outgoing_fee_rates: + fee_rates: operation: median min: 0 max: 100 diff --git a/policy/channels.go b/policy/channels.go index ce62e23..da79339 100644 --- a/policy/channels.go +++ b/policy/channels.go @@ -9,25 +9,32 @@ import ( // Channels represents a set of requirements that the initiator's node channels must satisfy. type Channels struct { - Number *Range[uint32] `yaml:"number,omitempty"` - Capacity *StatRange[int64] `yaml:"capacity,omitempty"` - ZeroBaseFees *bool `yaml:"zero_base_fees,omitempty"` - BlockHeight *StatRange[uint32] `yaml:"block_height,omitempty"` - TimeLockDelta *StatRange[uint32] `yaml:"time_lock_delta,omitempty"` - MinHTLC *StatRange[int64] `yaml:"min_htlc,omitempty"` - MaxHTLC *StatRange[uint64] `yaml:"max_htlc,omitempty"` - LastUpdateDiff *StatRange[uint32] `yaml:"last_update_diff,omitempty"` - Together *Range[int] `yaml:"together,omitempty"` - IncomingFeeRates *StatRange[int64] `yaml:"incoming_fee_rates,omitempty"` - OutgoingFeeRates *StatRange[int64] `yaml:"outgoing_fee_rates,omitempty"` - IncomingBaseFees *StatRange[int64] `yaml:"incoming_base_fees,omitempty"` - OutgoingBaseFees *StatRange[int64] `yaml:"outgoing_base_fees,omitempty"` - IncomingDisabled *StatRange[float64] `yaml:"incoming_disabled,omitempty"` - OutgoingDisabled *StatRange[float64] `yaml:"outgoing_disabled,omitempty"` - IncomingInboundFeeRates *StatRange[int32] `yaml:"incoming_inbound_fees_rates,omitempty"` - OutgoingInboundFeeRates *StatRange[int32] `yaml:"outgoing_inbound_fees_rates,omitempty"` - IncomingInboundBaseFees *StatRange[int32] `yaml:"incoming_inbound_base_fees,omitempty"` - OutgoingInboundBaseFees *StatRange[int32] `yaml:"outgoing_inbound_base_fees,omitempty"` + Number *Range[uint32] `yaml:"number,omitempty"` + Capacity *StatRange[int64] `yaml:"capacity,omitempty"` + ZeroBaseFees *bool `yaml:"zero_base_fees,omitempty"` + BlockHeight *StatRange[uint32] `yaml:"block_height,omitempty"` + TimeLockDelta *StatRange[uint32] `yaml:"time_lock_delta,omitempty"` + MinHTLC *StatRange[int64] `yaml:"min_htlc,omitempty"` + MaxHTLC *StatRange[uint64] `yaml:"max_htlc,omitempty"` + LastUpdateDiff *StatRange[uint32] `yaml:"last_update_diff,omitempty"` + Together *Range[int] `yaml:"together,omitempty"` + FeeRates *StatRange[int64] `yaml:"fee_rates,omitempty"` + BaseFees *StatRange[int64] `yaml:"base_fees,omitempty"` + Disabled *StatRange[float64] `yaml:"disabled,omitempty"` + InboundFeeRates *StatRange[int32] `yaml:"inbound_fees_rates,omitempty"` + InboundBaseFees *StatRange[int32] `yaml:"inbound_base_fees,omitempty"` + Peers *Peers `yaml:"peers,omitempty"` +} + +// Peers contains information about the initiator node channels peers. +// +// Fields must be duplicated to follow the YAML structure desired. +type Peers struct { + FeeRates *StatRange[int64] `yaml:"fee_rates,omitempty"` + BaseFees *StatRange[int64] `yaml:"base_fees,omitempty"` + Disabled *StatRange[float64] `yaml:"disabled,omitempty"` + InboundFeeRates *StatRange[int32] `yaml:"inbound_fees_rates,omitempty"` + InboundBaseFees *StatRange[int32] `yaml:"inbound_base_fees,omitempty"` } func (c *Channels) evaluate(nodePublicKey string, peer *lnrpc.NodeInfo) error { @@ -71,44 +78,48 @@ func (c *Channels) evaluate(nodePublicKey string, peer *lnrpc.NodeInfo) error { return errors.New("Channels together " + c.Together.Reason()) } - if !checkStat(c.IncomingFeeRates, peer, feeRatesFunc(false)) { - return errors.New("Incoming fee rates " + c.IncomingFeeRates.Reason()) + if !checkStat(c.FeeRates, peer, feeRatesFunc(true)) { + return errors.New("Channels fee rates " + c.FeeRates.Reason()) } - if !checkStat(c.OutgoingFeeRates, peer, feeRatesFunc(true)) { - return errors.New("Outgoing fee rates " + c.OutgoingFeeRates.Reason()) + if !checkStat(c.BaseFees, peer, baseFeesFunc(true)) { + return errors.New("Channels base fees " + c.BaseFees.Reason()) } - if !checkStat(c.IncomingBaseFees, peer, baseFeesFunc(false)) { - return errors.New("Incoming base fees " + c.IncomingBaseFees.Reason()) + if !checkStat(c.InboundFeeRates, peer, inboundFeeRatesFunc(true)) { + return errors.New("Channels inbound fee rates " + c.InboundFeeRates.Reason()) } - if !checkStat(c.OutgoingBaseFees, peer, baseFeesFunc(true)) { - return errors.New("Outgoing base fees " + c.OutgoingBaseFees.Reason()) + if !checkStat(c.InboundBaseFees, peer, inboundBaseFeesFunc(true)) { + return errors.New("Channels inbound base fees " + c.InboundBaseFees.Reason()) } - if !checkStat(c.IncomingInboundFeeRates, peer, inboundFeeRatesFunc(false)) { - return errors.New("Incoming inbound fee rates " + c.IncomingInboundFeeRates.Reason()) + if !c.checkDisabled(peer) { + return errors.New("Disabled channels " + c.Disabled.Reason()) + } + + if c.Peers == nil { + return nil } - if !checkStat(c.OutgoingInboundFeeRates, peer, inboundFeeRatesFunc(true)) { - return errors.New("Outgoing inbound fee rates " + c.OutgoingInboundFeeRates.Reason()) + if !checkStat(c.Peers.FeeRates, peer, feeRatesFunc(false)) { + return errors.New("Peers fee rates " + c.Peers.FeeRates.Reason()) } - if !checkStat(c.IncomingInboundBaseFees, peer, inboundBaseFeesFunc(false)) { - return errors.New("Incoming inbound base fees " + c.IncomingInboundBaseFees.Reason()) + if !checkStat(c.Peers.BaseFees, peer, baseFeesFunc(false)) { + return errors.New("Peers base fees " + c.Peers.BaseFees.Reason()) } - if !checkStat(c.OutgoingInboundBaseFees, peer, inboundBaseFeesFunc(true)) { - return errors.New("Outgoing inbound base fees " + c.OutgoingInboundBaseFees.Reason()) + if !checkStat(c.Peers.InboundFeeRates, peer, inboundFeeRatesFunc(false)) { + return errors.New("Peers inbound fee rates " + c.Peers.InboundFeeRates.Reason()) } - if !c.checkIncomingDisabled(peer) { - return errors.New("Incoming disabled channels " + c.IncomingDisabled.Reason()) + if !checkStat(c.Peers.InboundBaseFees, peer, inboundBaseFeesFunc(false)) { + return errors.New("Peers inbound base fees " + c.Peers.InboundBaseFees.Reason()) } - if !c.checkOutgoingDisabled(peer) { - return errors.New("Outgoing disabled channels " + c.OutgoingDisabled.Reason()) + if !c.checkPeersDisabled(peer) { + return errors.New("Peers disabled channels " + c.Peers.Disabled.Reason()) } return nil @@ -144,38 +155,38 @@ func (c *Channels) checkTogether(nodePublicKey string, peer *lnrpc.NodeInfo) boo return c.Together.Contains(count) } -func (c *Channels) checkIncomingDisabled(peer *lnrpc.NodeInfo) bool { - if c.IncomingDisabled == nil { +func (c *Channels) checkDisabled(peer *lnrpc.NodeInfo) bool { + if c.Disabled == nil { return true } disabledChannels := make([]float64, len(peer.Channels)) for i, channel := range peer.Channels { - policy := getNodePolicy(peer.Node.PubKey, channel, false) + policy := getNodePolicy(peer.Node.PubKey, channel, true) if policy.Disabled { disabledChannels[i] = 1 } } - return c.IncomingDisabled.Contains(disabledChannels) + return c.Disabled.Contains(disabledChannels) } -func (c *Channels) checkOutgoingDisabled(peer *lnrpc.NodeInfo) bool { - if c.OutgoingDisabled == nil { +func (c *Channels) checkPeersDisabled(peer *lnrpc.NodeInfo) bool { + if c.Peers.Disabled == nil { return true } disabledChannels := make([]float64, len(peer.Channels)) for i, channel := range peer.Channels { - policy := getNodePolicy(peer.Node.PubKey, channel, true) + policy := getNodePolicy(peer.Node.PubKey, channel, false) if policy.Disabled { disabledChannels[i] = 1 } } - return c.OutgoingDisabled.Contains(disabledChannels) + return c.Peers.Disabled.Contains(disabledChannels) } func getNodePolicy(peerPublicKey string, channel *lnrpc.ChannelEdge, outgoing bool) *lnrpc.RoutingPolicy { diff --git a/policy/channels_test.go b/policy/channels_test.go index 88ea523..f14742e 100644 --- a/policy/channels_test.go +++ b/policy/channels_test.go @@ -29,8 +29,13 @@ func TestEvaluateChannels(t *testing.T) { channels: nil, }, { - desc: "Empty channels", - channels: &Channels{}, + desc: "Nil peers", + channels: &Channels{Peers: nil}, + peer: &lnrpc.NodeInfo{}, + }, + { + desc: "Empty channels and peers", + channels: &Channels{Peers: &Peers{}}, peer: &lnrpc.NodeInfo{}, }, { @@ -218,10 +223,12 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Incoming fee rates", + desc: "Peers fee rates", channels: &Channels{ - IncomingFeeRates: &StatRange[int64]{ - Max: &max64, + Peers: &Peers{ + FeeRates: &StatRange[int64]{ + Max: &max64, + }, }, }, peer: &lnrpc.NodeInfo{ @@ -240,9 +247,9 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Outgoing fee rates", + desc: "Fee rates", channels: &Channels{ - OutgoingFeeRates: &StatRange[int64]{ + FeeRates: &StatRange[int64]{ Max: &max64, }, }, @@ -262,10 +269,12 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Incoming base fees", + desc: "Peers base fees", channels: &Channels{ - IncomingBaseFees: &StatRange[int64]{ - Max: &max64, + Peers: &Peers{ + BaseFees: &StatRange[int64]{ + Max: &max64, + }, }, }, peer: &lnrpc.NodeInfo{ @@ -284,9 +293,9 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Outgoing base fees", + desc: "Base fees", channels: &Channels{ - OutgoingBaseFees: &StatRange[int64]{ + BaseFees: &StatRange[int64]{ Max: &max64, }, }, @@ -306,10 +315,12 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Incoming disabled", + desc: "Peers disabled", channels: &Channels{ - IncomingDisabled: &StatRange[float64]{ - Max: &maxFloat, + Peers: &Peers{ + Disabled: &StatRange[float64]{ + Max: &maxFloat, + }, }, }, peer: &lnrpc.NodeInfo{ @@ -328,9 +339,9 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Outgoing disabled", + desc: "Disabled", channels: &Channels{ - OutgoingDisabled: &StatRange[float64]{ + Disabled: &StatRange[float64]{ Max: &maxFloat, }, }, @@ -350,10 +361,12 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Incoming inbound fee rates", + desc: "Peers inbound fee rates", channels: &Channels{ - IncomingInboundFeeRates: &StatRange[int32]{ - Max: &max32, + Peers: &Peers{ + InboundFeeRates: &StatRange[int32]{ + Max: &max32, + }, }, }, peer: &lnrpc.NodeInfo{ @@ -372,9 +385,9 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Outgoing inbound fee rates", + desc: "Inbound fee rates", channels: &Channels{ - OutgoingInboundFeeRates: &StatRange[int32]{ + InboundFeeRates: &StatRange[int32]{ Max: &max32, }, }, @@ -394,10 +407,12 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Incoming inbound base fees", + desc: "Peers inbound base fees", channels: &Channels{ - IncomingInboundBaseFees: &StatRange[int32]{ - Max: &max32, + Peers: &Peers{ + InboundBaseFees: &StatRange[int32]{ + Max: &max32, + }, }, }, peer: &lnrpc.NodeInfo{ @@ -416,9 +431,9 @@ func TestEvaluateChannels(t *testing.T) { fail: true, }, { - desc: "Outgoing inbound base fees", + desc: "Inbound base fees", channels: &Channels{ - OutgoingInboundBaseFees: &StatRange[int32]{ + InboundBaseFees: &StatRange[int32]{ Max: &max32, }, }, @@ -679,19 +694,19 @@ func TestCheckTogether(t *testing.T) { }) } -func TestCheckIncomingDisabled(t *testing.T) { +func TestCheckPeersDisabled(t *testing.T) { peerPublicKey := "peer_public_key" value := 0.6 cases := []struct { - peer *lnrpc.NodeInfo - incomingDisabled *StatRange[float64] - desc string - expected bool + peer *lnrpc.NodeInfo + peersDisabled *StatRange[float64] + desc string + expected bool }{ { desc: "Maximum disabled channels rate met", - incomingDisabled: &StatRange[float64]{ + peersDisabled: &StatRange[float64]{ Operation: Mean, Max: &value, }, @@ -710,7 +725,7 @@ func TestCheckIncomingDisabled(t *testing.T) { }, { desc: "Maximum disabled channels rate not met", - incomingDisabled: &StatRange[float64]{ + peersDisabled: &StatRange[float64]{ Operation: Mean, Max: &value, }, @@ -729,7 +744,7 @@ func TestCheckIncomingDisabled(t *testing.T) { }, { desc: "Minimum disabled channels rate met", - incomingDisabled: &StatRange[float64]{ + peersDisabled: &StatRange[float64]{ Operation: Mean, Min: &value, }, @@ -748,7 +763,7 @@ func TestCheckIncomingDisabled(t *testing.T) { }, { desc: "Minimum disabled channels rate not met", - incomingDisabled: &StatRange[float64]{ + peersDisabled: &StatRange[float64]{ Operation: Mean, Min: &value, }, @@ -770,33 +785,35 @@ func TestCheckIncomingDisabled(t *testing.T) { for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { channels := Channels{ - IncomingDisabled: tc.incomingDisabled, + Peers: &Peers{ + Disabled: tc.peersDisabled, + }, } - actual := channels.checkIncomingDisabled(tc.peer) + actual := channels.checkPeersDisabled(tc.peer) assert.Equal(t, tc.expected, actual) }) } t.Run("Nil", func(t *testing.T) { - channels := Channels{} - assert.True(t, channels.checkIncomingDisabled(nil)) + channels := Channels{Peers: &Peers{}} + assert.True(t, channels.checkPeersDisabled(nil)) }) } -func TestCheckOutgoingDisabled(t *testing.T) { +func TestCheckDisabled(t *testing.T) { value := 0.6 peerPublicKey := "peer_public_key" cases := []struct { - peer *lnrpc.NodeInfo - outgoingDisabled *StatRange[float64] - desc string - expected bool + peer *lnrpc.NodeInfo + disabled *StatRange[float64] + desc string + expected bool }{ { desc: "Maximum disabled channels rate met", - outgoingDisabled: &StatRange[float64]{ + disabled: &StatRange[float64]{ Operation: Mean, Max: &value, }, @@ -827,7 +844,7 @@ func TestCheckOutgoingDisabled(t *testing.T) { }, { desc: "Maximum disabled channels rate not met", - outgoingDisabled: &StatRange[float64]{ + disabled: &StatRange[float64]{ Operation: Mean, Max: &value, }, @@ -858,7 +875,7 @@ func TestCheckOutgoingDisabled(t *testing.T) { }, { desc: "Minimum disabled channels rate met", - outgoingDisabled: &StatRange[float64]{ + disabled: &StatRange[float64]{ Operation: Mean, Min: &value, }, @@ -889,7 +906,7 @@ func TestCheckOutgoingDisabled(t *testing.T) { }, { desc: "Minimum disabled channels rate not met", - outgoingDisabled: &StatRange[float64]{ + disabled: &StatRange[float64]{ Operation: Mean, Min: &value, }, @@ -923,17 +940,17 @@ func TestCheckOutgoingDisabled(t *testing.T) { for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { channels := Channels{ - OutgoingDisabled: tc.outgoingDisabled, + Disabled: tc.disabled, } - actual := channels.checkOutgoingDisabled(tc.peer) + actual := channels.checkDisabled(tc.peer) assert.Equal(t, tc.expected, actual) }) } t.Run("Nil", func(t *testing.T) { channels := Channels{} - assert.True(t, channels.checkOutgoingDisabled(nil)) + assert.True(t, channels.checkDisabled(nil)) }) } @@ -954,7 +971,7 @@ func TestGetNodePolicy(t *testing.T) { outgoing bool }{ { - desc: "Get incoming node policy", + desc: "Get peers node policy", peerPublicKey: publicKey, channel: &lnrpc.ChannelEdge{ Node1Policy: expectedPolicy, @@ -964,7 +981,7 @@ func TestGetNodePolicy(t *testing.T) { outgoing: false, }, { - desc: "Get incoming node policy 2", + desc: "Get peers node policy 2", peerPublicKey: publicKey, channel: &lnrpc.ChannelEdge{ Node1Pub: publicKey, @@ -974,7 +991,7 @@ func TestGetNodePolicy(t *testing.T) { outgoing: false, }, { - desc: "Get outgoing node policy", + desc: "Get node policy", peerPublicKey: publicKey, channel: &lnrpc.ChannelEdge{ Node1Pub: publicKey, @@ -984,7 +1001,7 @@ func TestGetNodePolicy(t *testing.T) { outgoing: true, }, { - desc: "Get outgoing node policy 2", + desc: "Get node policy 2", peerPublicKey: publicKey, channel: &lnrpc.ChannelEdge{ Node1Policy: otherPolicy, @@ -1069,7 +1086,7 @@ func TestLastUpdateFunc(t *testing.T) { } func TestFeeRatesFunc(t *testing.T) { - t.Run("Incoming", func(t *testing.T) { + t.Run("Peers", func(t *testing.T) { expected := int64(1) peer := &lnrpc.NodeInfo{Node: &lnrpc.LightningNode{}} channel := &lnrpc.ChannelEdge{ @@ -1096,7 +1113,7 @@ func TestFeeRatesFunc(t *testing.T) { } func TestBaseFeesFunc(t *testing.T) { - t.Run("Incoming", func(t *testing.T) { + t.Run("Peers", func(t *testing.T) { expected := int64(3) peer := &lnrpc.NodeInfo{Node: &lnrpc.LightningNode{}} channel := &lnrpc.ChannelEdge{ @@ -1123,7 +1140,7 @@ func TestBaseFeesFunc(t *testing.T) { } func TestInboundFeeRatesFunc(t *testing.T) { - t.Run("Incoming", func(t *testing.T) { + t.Run("Peers", func(t *testing.T) { expected := int32(1) peer := &lnrpc.NodeInfo{Node: &lnrpc.LightningNode{}} channel := &lnrpc.ChannelEdge{ @@ -1150,7 +1167,7 @@ func TestInboundFeeRatesFunc(t *testing.T) { } func TestInboundBaseFeesFunc(t *testing.T) { - t.Run("Incoming", func(t *testing.T) { + t.Run("Peers", func(t *testing.T) { expected := int32(1) peer := &lnrpc.NodeInfo{Node: &lnrpc.LightningNode{}} channel := &lnrpc.ChannelEdge{