package autopilot import ( "errors" "fmt" "net" "sync" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/require" ) type moreChansResp struct { numMore uint32 amt btcutil.Amount } type moreChanArg struct { chans []LocalChannel balance btcutil.Amount } type mockConstraints struct { moreChansResps chan moreChansResp moreChanArgs chan moreChanArg quit chan struct{} } func (m *mockConstraints) ChannelBudget(chans []LocalChannel, balance btcutil.Amount) (btcutil.Amount, uint32) { if m.moreChanArgs != nil { moreChan := moreChanArg{ chans: chans, balance: balance, } select { case m.moreChanArgs <- moreChan: case <-m.quit: return 0, 0 } } select { case resp := <-m.moreChansResps: return resp.amt, resp.numMore case <-m.quit: return 0, 0 } } func (m *mockConstraints) MaxPendingOpens() uint16 { return 10 } func (m *mockConstraints) MinChanSize() btcutil.Amount { return 1e7 } func (m *mockConstraints) MaxChanSize() btcutil.Amount { return 1e8 } var _ AgentConstraints = (*mockConstraints)(nil) type mockHeuristic struct { nodeScoresResps chan map[NodeID]*NodeScore nodeScoresArgs chan directiveArg quit chan struct{} } type directiveArg struct { graph ChannelGraph amt btcutil.Amount chans []LocalChannel nodes map[NodeID]struct{} } func (m *mockHeuristic) Name() string { return "mock" } func (m *mockHeuristic) NodeScores(g ChannelGraph, chans []LocalChannel, chanSize btcutil.Amount, nodes map[NodeID]struct{}) ( map[NodeID]*NodeScore, error) { if m.nodeScoresArgs != nil { directive := directiveArg{ graph: g, amt: chanSize, chans: chans, nodes: nodes, } select { case m.nodeScoresArgs <- directive: case <-m.quit: return nil, errors.New("exiting") } } select { case resp := <-m.nodeScoresResps: return resp, nil case <-m.quit: return nil, errors.New("exiting") } } var _ AttachmentHeuristic = (*mockHeuristic)(nil) type openChanIntent struct { target *btcec.PublicKey amt btcutil.Amount private bool } type mockChanController struct { openChanSignals chan openChanIntent private bool } func (m *mockChanController) OpenChannel(target *btcec.PublicKey, amt btcutil.Amount) error { m.openChanSignals <- openChanIntent{ target: target, amt: amt, private: m.private, } return nil } func (m *mockChanController) CloseChannel(chanPoint *wire.OutPoint) error { return nil } var _ ChannelController = (*mockChanController)(nil) type testContext struct { constraints *mockConstraints heuristic *mockHeuristic chanController ChannelController graph testGraph agent *Agent walletBalance btcutil.Amount quit chan struct{} sync.Mutex } func setup(t *testing.T, initialChans []LocalChannel) *testContext { t.Helper() // First, we'll create all the dependencies that we'll need in order to // create the autopilot agent. self, err := randKey() require.NoError(t, err, "unable to generate key") quit := make(chan struct{}) heuristic := &mockHeuristic{ nodeScoresArgs: make(chan directiveArg), nodeScoresResps: make(chan map[NodeID]*NodeScore), quit: quit, } constraints := &mockConstraints{ moreChansResps: make(chan moreChansResp), moreChanArgs: make(chan moreChanArg), quit: quit, } chanController := &mockChanController{ openChanSignals: make(chan openChanIntent, 10), } memGraph, _ := newMemChanGraph(t) // We'll keep track of the funds available to the agent, to make sure // it correctly uses this value when querying the ChannelBudget. var availableFunds btcutil.Amount = 10 * btcutil.SatoshiPerBitcoin ctx := &testContext{ constraints: constraints, heuristic: heuristic, chanController: chanController, graph: memGraph, walletBalance: availableFunds, quit: quit, } // With the dependencies we created, we can now create the initial // agent itself. testCfg := Config{ Self: self, Heuristic: heuristic, ChanController: chanController, WalletBalance: func() (btcutil.Amount, error) { ctx.Lock() defer ctx.Unlock() return ctx.walletBalance, nil }, ConnectToPeer: func(*btcec.PublicKey, []net.Addr) (bool, error) { return false, nil }, DisconnectPeer: func(*btcec.PublicKey) error { return nil }, Graph: memGraph, Constraints: constraints, } agent, err := New(testCfg, initialChans) require.NoError(t, err, "unable to create agent") ctx.agent = agent // With the autopilot agent and all its dependencies we'll start the // primary controller goroutine. if err := agent.Start(); err != nil { t.Fatalf("unable to start agent: %v", err) } t.Cleanup(func() { // We must close quit before agent.Stop(), to make sure // ChannelBudget won't block preventing the agent from exiting. close(quit) agent.Stop() }) return ctx } // respondMoreChans consumes the moreChanArgs element and responds to the agent // with the given moreChansResp. func respondMoreChans(t *testing.T, testCtx *testContext, resp moreChansResp) { t.Helper() // The agent should now query the heuristic. select { case <-testCtx.constraints.moreChanArgs: case <-time.After(time.Second * 3): t.Fatalf("heuristic wasn't queried in time") } // We'll send the response. select { case testCtx.constraints.moreChansResps <- resp: case <-time.After(time.Second * 10): t.Fatalf("response wasn't sent in time") } } // respondNodeScores consumes the nodeScoresArgs element and responds to the // agent with the given node scores. func respondNodeScores(t *testing.T, testCtx *testContext, resp map[NodeID]*NodeScore) { t.Helper() // Send over an empty list of attachment directives, which should cause // the agent to return to waiting on a new signal. select { case <-testCtx.heuristic.nodeScoresArgs: case <-time.After(time.Second * 3): t.Fatalf("node scores weren't queried in time") } select { case testCtx.heuristic.nodeScoresResps <- resp: case <-time.After(time.Second * 10): t.Fatalf("node scores were not sent in time") } } // TestAgentChannelOpenSignal tests that upon receipt of a chanOpenUpdate, then // agent modifies its local state accordingly, and reconsults the heuristic. func TestAgentChannelOpenSignal(t *testing.T) { t.Parallel() testCtx := setup(t, nil) // We'll send an initial "no" response to advance the agent past its // initial check. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // Next we'll signal a new channel being opened by the backing LN node, // with a capacity of 1 BTC. newChan := LocalChannel{ ChanID: randChanID(), Balance: btcutil.SatoshiPerBitcoin, } testCtx.agent.OnChannelOpen(newChan) // The agent should now query the heuristic in order to determine its // next action as it local state has now been modified. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // At this point, the local state of the agent should // have also been updated to reflect that the LN node // now has an additional channel with one BTC. if _, ok := testCtx.agent.chanState[newChan.ChanID]; !ok { t.Fatalf("internal channel state wasn't updated") } // There shouldn't be a call to the Select method as we've returned // "false" for NeedMoreChans above. select { // If this send success, then Select was erroneously called and the // test should be failed. case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{}: t.Fatalf("Select was called but shouldn't have been") // This is the correct path as Select should've be called. default: } } // TestAgentHeuristicUpdateSignal tests that upon notification about a // heuristic update, the agent reconsults the heuristic. func TestAgentHeuristicUpdateSignal(t *testing.T) { t.Parallel() testCtx := setup(t, nil) pub, err := testCtx.graph.addRandNode() require.NoError(t, err, "unable to generate key") // We'll send an initial "no" response to advance the agent past its // initial check. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // Next we'll signal that one of the heuristics have been updated. testCtx.agent.OnHeuristicUpdate(testCtx.heuristic) // The update should trigger the agent to ask for a channel budget.so // we'll respond that there is a budget for opening 1 more channel. respondMoreChans(t, testCtx, moreChansResp{ numMore: 1, amt: 1 * btcutil.SatoshiPerBitcoin, }, ) // At this point, the agent should now be querying the heuristic for // scores. We'll respond. nodeID := NewNodeID(pub) scores := map[NodeID]*NodeScore{ nodeID: { NodeID: nodeID, Score: 0.5, }, } respondNodeScores(t, testCtx, scores) // Finally, this should result in the agent opening a channel. chanController := testCtx.chanController.(*mockChanController) select { case <-chanController.openChanSignals: case <-time.After(time.Second * 10): t.Fatalf("channel not opened in time") } } // A mockFailingChanController always fails to open a channel. type mockFailingChanController struct { } func (m *mockFailingChanController) OpenChannel(target *btcec.PublicKey, amt btcutil.Amount) error { return errors.New("failure") } func (m *mockFailingChanController) CloseChannel(chanPoint *wire.OutPoint) error { return nil } var _ ChannelController = (*mockFailingChanController)(nil) // TestAgentChannelFailureSignal tests that if an autopilot channel fails to // open, the agent is signalled to make a new decision. func TestAgentChannelFailureSignal(t *testing.T) { t.Parallel() testCtx := setup(t, nil) testCtx.chanController = &mockFailingChanController{} node, err := testCtx.graph.addRandNode() require.NoError(t, err, "unable to add node") // First ensure the agent will attempt to open a new channel. Return // that we need more channels, and have 5BTC to use. respondMoreChans(t, testCtx, moreChansResp{1, 5 * btcutil.SatoshiPerBitcoin}) // At this point, the agent should now be querying the heuristic to // request attachment directives, return a fake so the agent will // attempt to open a channel. var fakeDirective = &NodeScore{ NodeID: NewNodeID(node), Score: 0.5, } respondNodeScores( t, testCtx, map[NodeID]*NodeScore{ NewNodeID(node): fakeDirective, }, ) // At this point the agent will attempt to create a channel and fail. // Now ensure that the controller loop is re-executed. respondMoreChans(t, testCtx, moreChansResp{1, 5 * btcutil.SatoshiPerBitcoin}) respondNodeScores(t, testCtx, map[NodeID]*NodeScore{}) } // TestAgentChannelCloseSignal ensures that once the agent receives an outside // signal of a channel belonging to the backing LN node being closed, then it // will query the heuristic to make its next decision. func TestAgentChannelCloseSignal(t *testing.T) { t.Parallel() // We'll start the agent with two channels already being active. initialChans := []LocalChannel{ { ChanID: randChanID(), Balance: btcutil.SatoshiPerBitcoin, }, { ChanID: randChanID(), Balance: btcutil.SatoshiPerBitcoin * 2, }, } testCtx := setup(t, initialChans) // We'll send an initial "no" response to advance the agent past its // initial check. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // Next, we'll close both channels which should force the agent to // re-query the heuristic. testCtx.agent.OnChannelClose(initialChans[0].ChanID, initialChans[1].ChanID) // The agent should now query the heuristic in order to determine its // next action as it local state has now been modified. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // At this point, the local state of the agent should // have also been updated to reflect that the LN node // has no existing open channels. if len(testCtx.agent.chanState) != 0 { t.Fatalf("internal channel state wasn't updated") } // There shouldn't be a call to the Select method as we've returned // "false" for NeedMoreChans above. select { // If this send success, then Select was erroneously called and the // test should be failed. case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{}: t.Fatalf("Select was called but shouldn't have been") // This is the correct path as Select should've be called. default: } } // TestAgentBalanceUpdate ensures that once the agent receives an // outside signal concerning a balance update, then it will re-query the // heuristic to determine its next action. func TestAgentBalanceUpdate(t *testing.T) { t.Parallel() testCtx := setup(t, nil) // We'll send an initial "no" response to advance the agent past its // initial check. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // Next we'll send a new balance update signal to the agent, adding 5 // BTC to the amount of available funds. testCtx.Lock() testCtx.walletBalance += btcutil.SatoshiPerBitcoin * 5 testCtx.Unlock() testCtx.agent.OnBalanceChange() // The agent should now query the heuristic in order to determine its // next action as it local state has now been modified. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // At this point, the local state of the agent should // have also been updated to reflect that the LN node // now has an additional 5BTC available. if testCtx.agent.totalBalance != testCtx.walletBalance { t.Fatalf("expected %v wallet balance "+ "instead have %v", testCtx.agent.totalBalance, testCtx.walletBalance) } // There shouldn't be a call to the Select method as we've returned // "false" for NeedMoreChans above. select { // If this send success, then Select was erroneously called and the // test should be failed. case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{}: t.Fatalf("Select was called but shouldn't have been") // This is the correct path as Select should've be called. default: } } // TestAgentImmediateAttach tests that if an autopilot agent is created, and it // has enough funds available to create channels, then it does so immediately. func TestAgentImmediateAttach(t *testing.T) { t.Parallel() testCtx := setup(t, nil) const numChans = 5 // We'll generate 5 mock directives so it can progress within its loop. directives := make(map[NodeID]*NodeScore) nodeKeys := make(map[NodeID]struct{}) for i := 0; i < numChans; i++ { pub, err := testCtx.graph.addRandNode() if err != nil { t.Fatalf("unable to generate key: %v", err) } nodeID := NewNodeID(pub) directives[nodeID] = &NodeScore{ NodeID: nodeID, Score: 0.5, } nodeKeys[nodeID] = struct{}{} } // The very first thing the agent should do is query the NeedMoreChans // method on the passed heuristic. So we'll provide it with a response // that will kick off the main loop. respondMoreChans(t, testCtx, moreChansResp{ numMore: numChans, amt: 5 * btcutil.SatoshiPerBitcoin, }, ) // At this point, the agent should now be querying the heuristic to // requests attachment directives. With our fake directives created, // we'll now send then to the agent as a return value for the Select // function. respondNodeScores(t, testCtx, directives) // Finally, we should receive 5 calls to the OpenChannel method with // the exact same parameters that we specified within the attachment // directives. chanController := testCtx.chanController.(*mockChanController) for i := 0; i < numChans; i++ { select { case openChan := <-chanController.openChanSignals: if openChan.amt != btcutil.SatoshiPerBitcoin { t.Fatalf("invalid chan amt: expected %v, got %v", btcutil.SatoshiPerBitcoin, openChan.amt) } nodeID := NewNodeID(openChan.target) _, ok := nodeKeys[nodeID] if !ok { t.Fatalf("unexpected key: %v, not found", nodeID) } delete(nodeKeys, nodeID) case <-time.After(time.Second * 10): t.Fatalf("channel not opened in time") } } } // TestAgentPrivateChannels ensure that only requests for private channels are // sent if set. func TestAgentPrivateChannels(t *testing.T) { t.Parallel() testCtx := setup(t, nil) // The chanController should be initialized such that all of its open // channel requests are for private channels. testCtx.chanController.(*mockChanController).private = true const numChans = 5 // We'll generate 5 mock directives so the pubkeys will be found in the // agent's graph, and it can progress within its loop. directives := make(map[NodeID]*NodeScore) for i := 0; i < numChans; i++ { pub, err := testCtx.graph.addRandNode() if err != nil { t.Fatalf("unable to generate key: %v", err) } directives[NewNodeID(pub)] = &NodeScore{ NodeID: NewNodeID(pub), Score: 0.5, } } // The very first thing the agent should do is query the NeedMoreChans // method on the passed heuristic. So we'll provide it with a response // that will kick off the main loop. We'll send over a response // indicating that it should establish more channels, and give it a // budget of 5 BTC to do so. resp := moreChansResp{ numMore: numChans, amt: 5 * btcutil.SatoshiPerBitcoin, } respondMoreChans(t, testCtx, resp) // At this point, the agent should now be querying the heuristic to // requests attachment directives. With our fake directives created, // we'll now send then to the agent as a return value for the Select // function. respondNodeScores(t, testCtx, directives) // Finally, we should receive 5 calls to the OpenChannel method, each // specifying that it's for a private channel. chanController := testCtx.chanController.(*mockChanController) for i := 0; i < numChans; i++ { select { case openChan := <-chanController.openChanSignals: if !openChan.private { t.Fatal("expected open channel request to be private") } case <-time.After(10 * time.Second): t.Fatal("channel not opened in time") } } } // TestAgentPendingChannelState ensures that the agent properly factors in its // pending channel state when making decisions w.r.t if it needs more channels // or not, and if so, who is eligible to open new channels to. func TestAgentPendingChannelState(t *testing.T) { t.Parallel() testCtx := setup(t, nil) // We'll only return a single directive for a pre-chosen node. nodeKey, err := testCtx.graph.addRandNode() require.NoError(t, err, "unable to generate key") nodeID := NewNodeID(nodeKey) nodeDirective := &NodeScore{ NodeID: nodeID, Score: 0.5, } // Once again, we'll start by telling the agent as part of its first // query, that it needs more channels and has 3 BTC available for // attachment. We'll send over a response indicating that it should // establish more channels, and give it a budget of 1 BTC to do so. respondMoreChans(t, testCtx, moreChansResp{ numMore: 1, amt: btcutil.SatoshiPerBitcoin, }, ) respondNodeScores(t, testCtx, map[NodeID]*NodeScore{ nodeID: nodeDirective, }, ) // A request to open the channel should've also been sent. chanController := testCtx.chanController.(*mockChanController) select { case openChan := <-chanController.openChanSignals: chanAmt := testCtx.constraints.MaxChanSize() if openChan.amt != chanAmt { t.Fatalf("invalid chan amt: expected %v, got %v", chanAmt, openChan.amt) } if !openChan.target.IsEqual(nodeKey) { t.Fatalf("unexpected key: expected %x, got %x", nodeKey.SerializeCompressed(), openChan.target.SerializeCompressed()) } case <-time.After(time.Second * 10): t.Fatalf("channel wasn't opened in time") } // Now, in order to test that the pending state was properly updated, // we'll trigger a balance update in order to trigger a query to the // heuristic. testCtx.Lock() testCtx.walletBalance += 0.4 * btcutil.SatoshiPerBitcoin testCtx.Unlock() testCtx.agent.OnBalanceChange() // The heuristic should be queried, and the argument for the set of // channels passed in should include the pending channels that // should've been created above. select { // The request that we get should include a pending channel for the // one that we just created, otherwise the agent isn't properly // updating its internal state. case req := <-testCtx.constraints.moreChanArgs: chanAmt := testCtx.constraints.MaxChanSize() if len(req.chans) != 1 { t.Fatalf("should include pending chan in current "+ "state, instead have %v chans", len(req.chans)) } if req.chans[0].Balance != chanAmt { t.Fatalf("wrong chan balance: expected %v, got %v", req.chans[0].Balance, chanAmt) } if req.chans[0].Node != nodeID { t.Fatalf("wrong node ID: expected %x, got %x", nodeID, req.chans[0].Node[:]) } case <-time.After(time.Second * 10): t.Fatalf("need more chans wasn't queried in time") } // We'll send across a response indicating that it *does* need more // channels. select { case testCtx.constraints.moreChansResps <- moreChansResp{1, btcutil.SatoshiPerBitcoin}: case <-time.After(time.Second * 10): t.Fatalf("need more chans wasn't queried in time") } // The response above should prompt the agent to make a query to the // Select method. The arguments passed should reflect the fact that the // node we have a pending channel to, should be ignored. select { case req := <-testCtx.heuristic.nodeScoresArgs: if len(req.chans) == 0 { t.Fatalf("expected to skip %v nodes, instead "+ "skipping %v", 1, len(req.chans)) } if req.chans[0].Node != nodeID { t.Fatalf("pending node not included in skip arguments") } case <-time.After(time.Second * 10): t.Fatalf("select wasn't queried in time") } } // TestAgentPendingOpenChannel ensures that the agent queries its heuristic once // it detects a channel is pending open. This allows the agent to use its own // change outputs that have yet to confirm for funding transactions. func TestAgentPendingOpenChannel(t *testing.T) { t.Parallel() testCtx := setup(t, nil) // We'll send an initial "no" response to advance the agent past its // initial check. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // Next, we'll signal that a new channel has been opened, but it is // still pending. testCtx.agent.OnChannelPendingOpen() // The agent should now query the heuristic in order to determine its // next action as its local state has now been modified. respondMoreChans(t, testCtx, moreChansResp{0, 0}) // There shouldn't be a call to the Select method as we've returned // "false" for NeedMoreChans above. select { case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{}: t.Fatalf("Select was called but shouldn't have been") default: } } // TestAgentOnNodeUpdates tests that the agent will wake up in response to the // OnNodeUpdates signal. This is useful in ensuring that autopilot is always // pulling in the latest graph updates into its decision making. It also // prevents the agent from stalling after an initial attempt that finds no nodes // in the graph. func TestAgentOnNodeUpdates(t *testing.T) { t.Parallel() testCtx := setup(t, nil) // We'll send an initial "yes" response to advance the agent past its // initial check. This will cause it to try to get directives from an // empty graph. respondMoreChans( t, testCtx, moreChansResp{ numMore: 2, amt: testCtx.walletBalance, }, ) // Send over an empty list of attachment directives, which should cause // the agent to return to waiting on a new signal. respondNodeScores(t, testCtx, map[NodeID]*NodeScore{}) // Simulate more nodes being added to the graph by informing the agent // that we have node updates. testCtx.agent.OnNodeUpdates() // In response, the agent should wake up and see if it needs more // channels. Since we haven't done anything, we will send the same // response as before since we are still trying to open channels. respondMoreChans( t, testCtx, moreChansResp{ numMore: 2, amt: testCtx.walletBalance, }, ) // Again the agent should pull in the next set of attachment directives. // It's not important that this list is also empty, so long as the node // updates signal is causing the agent to make this attempt. respondNodeScores(t, testCtx, map[NodeID]*NodeScore{}) } // TestAgentSkipPendingConns asserts that the agent will not try to make // duplicate connection requests to the same node, even if the attachment // heuristic instructs the agent to do so. It also asserts that the agent // stops tracking the pending connection once it finishes. Note that in // practice, a failed connection would be inserted into the skip map passed to // the attachment heuristic, though this does not assert that case. func TestAgentSkipPendingConns(t *testing.T) { t.Parallel() testCtx := setup(t, nil) connect := make(chan chan error) testCtx.agent.cfg.ConnectToPeer = func(*btcec.PublicKey, []net.Addr) (bool, error) { errChan := make(chan error) select { case connect <- errChan: case <-testCtx.quit: return false, errors.New("quit") } select { case err := <-errChan: return false, err case <-testCtx.quit: return false, errors.New("quit") } } // We'll only return a single directive for a pre-chosen node. nodeKey, err := testCtx.graph.addRandNode() require.NoError(t, err, "unable to generate key") nodeID := NewNodeID(nodeKey) nodeDirective := &NodeScore{ NodeID: nodeID, Score: 0.5, } // We'll also add a second node to the graph, to keep the first one // company. nodeKey2, err := testCtx.graph.addRandNode() require.NoError(t, err, "unable to generate key") nodeID2 := NewNodeID(nodeKey2) // We'll send an initial "yes" response to advance the agent past its // initial check. This will cause it to try to get directives from the // graph. respondMoreChans(t, testCtx, moreChansResp{ numMore: 1, amt: testCtx.walletBalance, }, ) // Both nodes should be part of the arguments. select { case req := <-testCtx.heuristic.nodeScoresArgs: if len(req.nodes) != 2 { t.Fatalf("expected %v nodes, instead "+ "had %v", 2, len(req.nodes)) } if _, ok := req.nodes[nodeID]; !ok { t.Fatalf("node not included in arguments") } if _, ok := req.nodes[nodeID2]; !ok { t.Fatalf("node not included in arguments") } case <-time.After(time.Second * 10): t.Fatalf("select wasn't queried in time") } // Respond with a scored directive. We skip node2 for now, implicitly // giving it a zero-score. select { case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{ NewNodeID(nodeKey): nodeDirective, }: case <-time.After(time.Second * 10): t.Fatalf("heuristic wasn't queried in time") } // The agent should attempt connection to the node. var errChan chan error select { case errChan = <-connect: case <-time.After(time.Second * 10): t.Fatalf("agent did not attempt connection") } // Signal the agent to go again, now that we've tried to connect. testCtx.agent.OnNodeUpdates() // The heuristic again informs the agent that we need more channels. respondMoreChans(t, testCtx, moreChansResp{ numMore: 1, amt: testCtx.walletBalance, }, ) // Since the node now has a pending connection, it should be skipped // and not part of the nodes attempting to be scored. select { case req := <-testCtx.heuristic.nodeScoresArgs: if len(req.nodes) != 1 { t.Fatalf("expected %v nodes, instead "+ "had %v", 1, len(req.nodes)) } if _, ok := req.nodes[nodeID2]; !ok { t.Fatalf("node not included in arguments") } case <-time.After(time.Second * 10): t.Fatalf("select wasn't queried in time") } // Respond with an emtpty score set. select { case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{}: case <-time.After(time.Second * 10): t.Fatalf("heuristic wasn't queried in time") } // The agent should not attempt any connection, since no nodes were // scored. select { case <-connect: t.Fatalf("agent should not have attempted connection") case <-time.After(time.Second * 3): } // Now, timeout the original request, which should still be waiting for // a response. select { case errChan <- fmt.Errorf("connection timeout"): case <-time.After(time.Second * 10): t.Fatalf("agent did not receive connection timeout") } // The agent will now retry since the last connection attempt failed. // The heuristic again informs the agent that we need more channels. respondMoreChans(t, testCtx, moreChansResp{ numMore: 1, amt: testCtx.walletBalance, }, ) // The node should now be marked as "failed", which should make it // being skipped during scoring. Again check that it won't be among the // score request. select { case req := <-testCtx.heuristic.nodeScoresArgs: if len(req.nodes) != 1 { t.Fatalf("expected %v nodes, instead "+ "had %v", 1, len(req.nodes)) } if _, ok := req.nodes[nodeID2]; !ok { t.Fatalf("node not included in arguments") } case <-time.After(time.Second * 10): t.Fatalf("select wasn't queried in time") } // Send a directive for the second node. nodeDirective2 := &NodeScore{ NodeID: nodeID2, Score: 0.5, } select { case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{ nodeID2: nodeDirective2, }: case <-time.After(time.Second * 10): t.Fatalf("heuristic wasn't queried in time") } // This time, the agent should try the connection to the second node. select { case <-connect: case <-time.After(time.Second * 10): t.Fatalf("agent should have attempted connection") } } // TestAgentQuitWhenPendingConns tests that we are able to stop the autopilot // agent even though there are pending connections to nodes. func TestAgentQuitWhenPendingConns(t *testing.T) { t.Parallel() testCtx := setup(t, nil) connect := make(chan chan error) testCtx.agent.cfg.ConnectToPeer = func(*btcec.PublicKey, []net.Addr) (bool, error) { errChan := make(chan error) select { case connect <- errChan: case <-testCtx.quit: return false, errors.New("quit") } select { case err := <-errChan: return false, err case <-testCtx.quit: return false, errors.New("quit") } } // We'll only return a single directive for a pre-chosen node. nodeKey, err := testCtx.graph.addRandNode() require.NoError(t, err, "unable to generate key") nodeID := NewNodeID(nodeKey) nodeDirective := &NodeScore{ NodeID: nodeID, Score: 0.5, } // We'll send an initial "yes" response to advance the agent past its // initial check. This will cause it to try to get directives from the // graph. respondMoreChans(t, testCtx, moreChansResp{ numMore: 1, amt: testCtx.walletBalance, }, ) // Check the args. select { case req := <-testCtx.heuristic.nodeScoresArgs: if len(req.nodes) != 1 { t.Fatalf("expected %v nodes, instead "+ "had %v", 1, len(req.nodes)) } if _, ok := req.nodes[nodeID]; !ok { t.Fatalf("node not included in arguments") } case <-time.After(time.Second * 10): t.Fatalf("select wasn't queried in time") } // Respond with a scored directive. select { case testCtx.heuristic.nodeScoresResps <- map[NodeID]*NodeScore{ NewNodeID(nodeKey): nodeDirective, }: case <-time.After(time.Second * 10): t.Fatalf("heuristic wasn't queried in time") } // The agent should attempt connection to the node. select { case <-connect: case <-time.After(time.Second * 10): t.Fatalf("agent did not attempt connection") } // Make sure that we are able to stop the agent, even though there is a // pending connection. stopped := make(chan error) go func() { stopped <- testCtx.agent.Stop() }() select { case err := <-stopped: if err != nil { t.Fatalf("error stopping agent: %v", err) } case <-time.After(2 * time.Second): t.Fatalf("unable to stop agent") } } // respondWithScores checks that the moreChansRequest contains what we expect, // and responds with the given node scores. func respondWithScores(t *testing.T, testCtx *testContext, channelBudget btcutil.Amount, existingChans, newChans int, nodeScores map[NodeID]*NodeScore) { t.Helper() select { case testCtx.constraints.moreChansResps <- moreChansResp{ numMore: uint32(newChans), amt: channelBudget, }: case <-time.After(time.Second * 3): t.Fatalf("heuristic wasn't queried in time") } // The agent should query for scores using the constraints returned // above. We expect the agent to use the maximum channel size when // opening channels. chanSize := testCtx.constraints.MaxChanSize() select { case req := <-testCtx.heuristic.nodeScoresArgs: // All nodes in the graph should be potential channel // candidates. if len(req.nodes) != len(nodeScores) { t.Fatalf("expected %v nodes, instead had %v", len(nodeScores), len(req.nodes)) } // 'existingChans' is already open. if len(req.chans) != existingChans { t.Fatalf("expected %d existing channel, got %v", existingChans, len(req.chans)) } if req.amt != chanSize { t.Fatalf("expected channel size of %v, got %v", chanSize, req.amt) } case <-time.After(time.Second * 3): t.Fatalf("select wasn't queried in time") } // Respond with the given scores. select { case testCtx.heuristic.nodeScoresResps <- nodeScores: case <-time.After(time.Second * 3): t.Fatalf("NodeScores wasn't queried in time") } } // checkChannelOpens asserts that the channel controller attempts open the // number of channels we expect, and with the exact total allocation. func checkChannelOpens(t *testing.T, testCtx *testContext, allocation btcutil.Amount, numChans int) []NodeID { var nodes []NodeID // The agent should attempt to open channels, totaling what we expect. var totalAllocation btcutil.Amount chanController := testCtx.chanController.(*mockChanController) for i := 0; i < numChans; i++ { select { case openChan := <-chanController.openChanSignals: totalAllocation += openChan.amt testCtx.Lock() testCtx.walletBalance -= openChan.amt testCtx.Unlock() nodes = append(nodes, NewNodeID(openChan.target)) case <-time.After(time.Second * 3): t.Fatalf("channel not opened in time") } } if totalAllocation != allocation { t.Fatalf("expected agent to open channels totalling %v, "+ "instead was %v", allocation, totalAllocation) } // Finally, make sure the agent won't try opening more channels. select { case <-chanController.openChanSignals: t.Fatalf("agent unexpectedly opened channel") case <-time.After(50 * time.Millisecond): } return nodes } // TestAgentChannelSizeAllocation tests that the autopilot agent opens channel // of size that stays within the channel budget and size restrictions. func TestAgentChannelSizeAllocation(t *testing.T) { t.Parallel() // Total number of nodes in our mock graph. const numNodes = 20 testCtx := setup(t, nil) nodeScores := make(map[NodeID]*NodeScore) for i := 0; i < numNodes; i++ { nodeKey, err := testCtx.graph.addRandNode() if err != nil { t.Fatalf("unable to generate key: %v", err) } nodeID := NewNodeID(nodeKey) nodeScores[nodeID] = &NodeScore{ NodeID: nodeID, Score: 0.5, } } // The agent should now query the heuristic in order to determine its // next action as it local state has now been modified. select { case arg := <-testCtx.constraints.moreChanArgs: if len(arg.chans) != 0 { t.Fatalf("expected agent to have no channels open, "+ "had %v", len(arg.chans)) } if arg.balance != testCtx.walletBalance { t.Fatalf("expected agent to have %v balance, had %v", testCtx.walletBalance, arg.balance) } case <-time.After(time.Second * 3): t.Fatalf("heuristic wasn't queried in time") } // We'll return a response telling the agent to open 5 channels, with a // total channel budget of 5 BTC. var channelBudget btcutil.Amount = 5 * btcutil.SatoshiPerBitcoin numExistingChannels := 0 numNewChannels := 5 respondWithScores( t, testCtx, channelBudget, numExistingChannels, numNewChannels, nodeScores, ) // We expect the autopilot to have allocated all funds towards // channels. expectedAllocation := testCtx.constraints.MaxChanSize() * btcutil.Amount(numNewChannels) nodes := checkChannelOpens( t, testCtx, expectedAllocation, numNewChannels, ) // Delete the selected nodes from our set of scores, to avoid scoring // nodes we already have channels to. for _, node := range nodes { delete(nodeScores, node) } // TODO(halseth): this loop is a hack to ensure all the attempted // channels are accounted for. This happens because the agent will // query the ChannelBudget before all the pending channels are added to // the map. Fix by adding them to the pending channels map before // executing directives in goroutines? waitForNumChans := func(expChans int) { t.Helper() var ( numChans int balance btcutil.Amount ) Loop: for { select { case arg := <-testCtx.constraints.moreChanArgs: numChans = len(arg.chans) balance = arg.balance // As long as the number of existing channels // is below our expected number of channels, // and the balance is not what we expect, we'll // keep responding with "no more channels". if numChans == expChans && balance == testCtx.walletBalance { break Loop } select { case testCtx.constraints.moreChansResps <- moreChansResp{0, 0}: case <-time.After(time.Second * 3): t.Fatalf("heuristic wasn't queried " + "in time") } case <-time.After(time.Second * 3): t.Fatalf("did not receive expected "+ "channels(%d) and balance(%d), "+ "instead got %d and %d", expChans, testCtx.walletBalance, numChans, balance) } } } // Wait for the agent to have 5 channels. waitForNumChans(numNewChannels) // Set the channel budget to 1.5 BTC. channelBudget = btcutil.SatoshiPerBitcoin * 3 / 2 // We'll return a response telling the agent to open 3 channels, with a // total channel budget of 1.5 BTC. numExistingChannels = 5 numNewChannels = 3 respondWithScores( t, testCtx, channelBudget, numExistingChannels, numNewChannels, nodeScores, ) // To stay within the budget, we expect the autopilot to open 2 // channels. expectedAllocation = channelBudget nodes = checkChannelOpens(t, testCtx, expectedAllocation, 2) numExistingChannels = 7 for _, node := range nodes { delete(nodeScores, node) } waitForNumChans(numExistingChannels) // Finally check that we make maximum channels if we are well within // our budget. channelBudget = btcutil.SatoshiPerBitcoin * 5 numNewChannels = 2 respondWithScores( t, testCtx, channelBudget, numExistingChannels, numNewChannels, nodeScores, ) // We now expect the autopilot to open 2 channels, and since it has // more than enough balance within the budget, they should both be of // maximum size. expectedAllocation = testCtx.constraints.MaxChanSize() * btcutil.Amount(numNewChannels) checkChannelOpens(t, testCtx, expectedAllocation, numNewChannels) }