diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
index 7c6e77d0abfee7f80730f66c9e2caf0324392426..5c9ec6fa53d543770835ec4acffe2abd62eae3bf 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
@@ -8,6 +8,7 @@ import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.contact.PendingContact;
 import org.briarproject.bramble.api.contact.PendingContactId;
 import org.briarproject.bramble.api.contact.PendingContactState;
+import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent;
 import org.briarproject.bramble.api.crypto.KeyPair;
 import org.briarproject.bramble.api.crypto.PublicKey;
 import org.briarproject.bramble.api.crypto.SecretKey;
@@ -15,24 +16,34 @@ import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.event.Event;
+import org.briarproject.bramble.api.event.EventBus;
+import org.briarproject.bramble.api.event.EventListener;
 import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.identity.AuthorInfo;
 import org.briarproject.bramble.api.identity.IdentityManager;
 import org.briarproject.bramble.api.identity.LocalAuthor;
 import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
 import org.briarproject.bramble.api.transport.KeyManager;
 
 import java.security.GeneralSecurityException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 import javax.inject.Inject;
 
+import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
+import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
 import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.OURSELVES;
@@ -43,24 +54,29 @@ import static org.briarproject.bramble.util.StringUtils.toUtf8;
 
 @ThreadSafe
 @NotNullByDefault
-class ContactManagerImpl implements ContactManager {
+class ContactManagerImpl implements ContactManager, EventListener {
 
 	private final DatabaseComponent db;
 	private final KeyManager keyManager;
 	private final IdentityManager identityManager;
 	private final PendingContactFactory pendingContactFactory;
+	private final EventBus eventBus;
 
-	private final List<ContactHook> hooks;
+	private final List<ContactHook> hooks = new CopyOnWriteArrayList<>();
+	private final ConcurrentMap<PendingContactId, PendingContactState> states =
+			new ConcurrentHashMap<>();
 
 	@Inject
-	ContactManagerImpl(DatabaseComponent db, KeyManager keyManager,
+	ContactManagerImpl(DatabaseComponent db,
+			KeyManager keyManager,
 			IdentityManager identityManager,
-			PendingContactFactory pendingContactFactory) {
+			PendingContactFactory pendingContactFactory,
+			EventBus eventBus) {
 		this.db = db;
 		this.keyManager = keyManager;
 		this.identityManager = identityManager;
 		this.pendingContactFactory = pendingContactFactory;
-		hooks = new CopyOnWriteArrayList<>();
+		this.eventBus = eventBus;
 	}
 
 	@Override
@@ -86,6 +102,7 @@ class ContactManagerImpl implements ContactManager {
 			throws DbException, GeneralSecurityException {
 		PendingContact pendingContact = db.getPendingContact(txn, p);
 		db.removePendingContact(txn, p);
+		states.remove(p);
 		PublicKey theirPublicKey = pendingContact.getPublicKey();
 		ContactId c =
 				db.addContact(txn, remote, local, theirPublicKey, verified);
@@ -139,6 +156,7 @@ class ContactManagerImpl implements ContactManager {
 		} finally {
 			db.endTransaction(txn);
 		}
+		setState(p.getId(), WAITING_FOR_CONNECTION);
 		return p;
 	}
 
@@ -156,7 +174,9 @@ class ContactManagerImpl implements ContactManager {
 		List<Pair<PendingContact, PendingContactState>> pairs =
 				new ArrayList<>(pendingContacts.size());
 		for (PendingContact p : pendingContacts) {
-			pairs.add(new Pair<>(p, WAITING_FOR_CONNECTION)); // TODO
+			PendingContactState state = states.get(p.getId());
+			if (state == null) state = WAITING_FOR_CONNECTION;
+			pairs.add(new Pair<>(p, state));
 		}
 		return pairs;
 	}
@@ -164,6 +184,7 @@ class ContactManagerImpl implements ContactManager {
 	@Override
 	public void removePendingContact(PendingContactId p) throws DbException {
 		db.transaction(false, txn -> db.removePendingContact(txn, p));
+		states.remove(p);
 	}
 
 	@Override
@@ -263,4 +284,48 @@ class ContactManagerImpl implements ContactManager {
 		else return new AuthorInfo(UNVERIFIED, c.getAlias());
 	}
 
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof RendezvousConnectionOpenedEvent) {
+			RendezvousConnectionOpenedEvent r =
+					(RendezvousConnectionOpenedEvent) e;
+			setStateConnected(r.getPendingContactId());
+		} else if (e instanceof RendezvousConnectionClosedEvent) {
+			RendezvousConnectionClosedEvent r =
+					(RendezvousConnectionClosedEvent) e;
+			// We're only interested in failures - if the rendezvous succeeds
+			// the pending contact will be removed
+			if (!r.isSuccess()) setStateDisconnected(r.getPendingContactId());
+		} else if (e instanceof RendezvousFailedEvent) {
+			RendezvousFailedEvent r = (RendezvousFailedEvent) e;
+			setState(r.getPendingContactId(), FAILED);
+		}
+	}
+
+	/**
+	 * Sets the state of the given pending contact and broadcasts an event.
+	 */
+	private void setState(PendingContactId p, PendingContactState state) {
+		states.put(p, state);
+		eventBus.broadcast(new PendingContactStateChangedEvent(p, state));
+	}
+
+	private void setStateConnected(PendingContactId p) {
+		// Set the state to ADDING_CONTACT if there's no current state or the
+		// current state is WAITING_FOR_CONNECTION
+		if (states.putIfAbsent(p, ADDING_CONTACT) == null ||
+				states.replace(p, WAITING_FOR_CONNECTION, ADDING_CONTACT)) {
+			eventBus.broadcast(new PendingContactStateChangedEvent(p,
+					ADDING_CONTACT));
+		}
+	}
+
+	private void setStateDisconnected(PendingContactId p) {
+		// Set the state to WAITING_FOR_CONNECTION if the current state is
+		// ADDING_CONTACT
+		if (states.replace(p, ADDING_CONTACT, WAITING_FOR_CONNECTION)) {
+			eventBus.broadcast(new PendingContactStateChangedEvent(p,
+					WAITING_FOR_CONNECTION));
+		}
+	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
index f4d6df9f3d0caec5cf3ddfc69b4ace9635de452f..f0b5b6259f072fd2d03fd7f750b2ab44656ab655 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
@@ -3,6 +3,7 @@ package org.briarproject.bramble.contact;
 import org.briarproject.bramble.api.contact.ContactExchangeManager;
 import org.briarproject.bramble.api.contact.ContactManager;
 import org.briarproject.bramble.api.contact.HandshakeManager;
+import org.briarproject.bramble.api.event.EventBus;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -20,7 +21,9 @@ public class ContactModule {
 
 	@Provides
 	@Singleton
-	ContactManager provideContactManager(ContactManagerImpl contactManager) {
+	ContactManager provideContactManager(EventBus eventBus,
+			ContactManagerImpl contactManager) {
+		eventBus.addListener(contactManager);
 		return contactManager;
 	}
 
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
index 1eab88e887710bd2b5a0fa0c6818880e6ebe937c..b0a50ec161be7868f6faa56f0e9fdcf5addd6cb2 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
@@ -1,25 +1,32 @@
 package org.briarproject.bramble.contact;
 
+import org.briarproject.bramble.api.Pair;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
-import org.briarproject.bramble.api.contact.ContactManager;
+import org.briarproject.bramble.api.contact.PendingContact;
+import org.briarproject.bramble.api.contact.PendingContactState;
+import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent;
 import org.briarproject.bramble.api.crypto.KeyPair;
-import org.briarproject.bramble.api.crypto.PrivateKey;
-import org.briarproject.bramble.api.crypto.PublicKey;
 import org.briarproject.bramble.api.crypto.SecretKey;
 import org.briarproject.bramble.api.db.DatabaseComponent;
 import org.briarproject.bramble.api.db.DbException;
 import org.briarproject.bramble.api.db.NoSuchContactException;
 import org.briarproject.bramble.api.db.Transaction;
+import org.briarproject.bramble.api.event.EventBus;
 import org.briarproject.bramble.api.identity.Author;
 import org.briarproject.bramble.api.identity.AuthorId;
 import org.briarproject.bramble.api.identity.AuthorInfo;
 import org.briarproject.bramble.api.identity.IdentityManager;
 import org.briarproject.bramble.api.identity.LocalAuthor;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionClosedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousConnectionOpenedEvent;
+import org.briarproject.bramble.api.rendezvous.event.RendezvousFailedEvent;
 import org.briarproject.bramble.api.transport.KeyManager;
 import org.briarproject.bramble.test.BrambleMockTestCase;
 import org.briarproject.bramble.test.DbExpectations;
+import org.briarproject.bramble.test.PredicateMatcher;
 import org.jmock.Expectations;
+import org.junit.Before;
 import org.junit.Test;
 
 import java.util.Collection;
@@ -28,6 +35,9 @@ import java.util.Random;
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.BASE32_LINK_BYTES;
+import static org.briarproject.bramble.api.contact.PendingContactState.ADDING_CONTACT;
+import static org.briarproject.bramble.api.contact.PendingContactState.FAILED;
+import static org.briarproject.bramble.api.contact.PendingContactState.WAITING_FOR_CONNECTION;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.OURSELVES;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.UNKNOWN;
@@ -38,6 +48,7 @@ import static org.briarproject.bramble.test.TestUtils.getAgreementPublicKey;
 import static org.briarproject.bramble.test.TestUtils.getAuthor;
 import static org.briarproject.bramble.test.TestUtils.getContact;
 import static org.briarproject.bramble.test.TestUtils.getLocalAuthor;
+import static org.briarproject.bramble.test.TestUtils.getPendingContact;
 import static org.briarproject.bramble.test.TestUtils.getRandomId;
 import static org.briarproject.bramble.test.TestUtils.getSecretKey;
 import static org.briarproject.bramble.util.StringUtils.getRandomBase32String;
@@ -54,24 +65,31 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 			context.mock(IdentityManager.class);
 	private final PendingContactFactory pendingContactFactory =
 			context.mock(PendingContactFactory.class);
-	private final ContactManager contactManager;
+	private final EventBus eventBus = context.mock(EventBus.class);
+
 	private final Author remote = getAuthor();
 	private final LocalAuthor localAuthor = getLocalAuthor();
 	private final AuthorId local = localAuthor.getId();
 	private final boolean verified = false, active = true;
 	private final Contact contact = getContact(remote, local, verified);
 	private final ContactId contactId = contact.getId();
+	private final KeyPair handshakeKeyPair =
+			new KeyPair(getAgreementPublicKey(), getAgreementPrivateKey());
+	private final PendingContact pendingContact = getPendingContact();
+	private final SecretKey rootKey = getSecretKey();
+	private final long timestamp = System.currentTimeMillis();
+	private final boolean alice = new Random().nextBoolean();
+
+	private ContactManagerImpl contactManager;
 
-	public ContactManagerImplTest() {
+	@Before
+	public void setUp() {
 		contactManager = new ContactManagerImpl(db, keyManager,
-				identityManager, pendingContactFactory);
+				identityManager, pendingContactFactory, eventBus);
 	}
 
 	@Test
 	public void testAddContact() throws Exception {
-		SecretKey rootKey = getSecretKey();
-		long timestamp = System.currentTimeMillis();
-		boolean alice = new Random().nextBoolean();
 		Transaction txn = new Transaction(null, false);
 
 		context.checking(new DbExpectations() {{
@@ -91,6 +109,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	@Test
 	public void testGetContact() throws Exception {
 		Transaction txn = new Transaction(null, true);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(db).getContact(txn, contactId);
@@ -104,6 +123,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	public void testGetContactByAuthor() throws Exception {
 		Transaction txn = new Transaction(null, true);
 		Collection<Contact> contacts = singletonList(contact);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(db).getContactsByAuthorId(txn, remote.getId());
@@ -116,6 +136,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	@Test(expected = NoSuchContactException.class)
 	public void testGetContactByUnknownAuthor() throws Exception {
 		Transaction txn = new Transaction(null, true);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(db).getContactsByAuthorId(txn, remote.getId());
@@ -129,6 +150,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	public void testGetContactByUnknownLocalAuthor() throws Exception {
 		Transaction txn = new Transaction(null, true);
 		Collection<Contact> contacts = singletonList(contact);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(db).getContactsByAuthorId(txn, remote.getId());
@@ -142,6 +164,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	public void testGetContacts() throws Exception {
 		Collection<Contact> contacts = singletonList(contact);
 		Transaction txn = new Transaction(null, true);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(db).getContacts(txn);
@@ -154,6 +177,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	@Test
 	public void testRemoveContact() throws Exception {
 		Transaction txn = new Transaction(null, false);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transaction(with(false), withDbRunnable(txn));
 			oneOf(db).getContact(txn, contactId);
@@ -187,6 +211,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	@Test
 	public void testContactExists() throws Exception {
 		Transaction txn = new Transaction(null, true);
+
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(db).containsContact(txn, remote.getId(), local);
@@ -206,6 +231,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 			oneOf(db).getContactsByAuthorId(txn, remote.getId());
 			will(returnValue(singletonList(contact)));
 		}});
+
 		AuthorInfo authorInfo =
 				contactManager.getAuthorInfo(txn, remote.getId());
 		assertEquals(UNVERIFIED, authorInfo.getStatus());
@@ -223,6 +249,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 			oneOf(db).getContactsByAuthorId(txn, remote.getId());
 			will(returnValue(emptyList()));
 		}});
+
 		AuthorInfo authorInfo =
 				contactManager.getAuthorInfo(txn, remote.getId());
 		assertEquals(UNKNOWN, authorInfo.getStatus());
@@ -247,6 +274,7 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 			will(returnValue(localAuthor));
 			never(db).getContactsByAuthorId(txn, remote.getId());
 		}});
+
 		authorInfo = contactManager.getAuthorInfo(txn, localAuthor.getId());
 		assertEquals(OURSELVES, authorInfo.getStatus());
 		assertNull(authorInfo.getAlias());
@@ -265,19 +293,178 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	@Test
 	public void testGetHandshakeLink() throws Exception {
 		Transaction txn = new Transaction(null, true);
-		PublicKey publicKey = getAgreementPublicKey();
-		PrivateKey privateKey = getAgreementPrivateKey();
-		KeyPair keyPair = new KeyPair(publicKey, privateKey);
 		String link = "briar://" + getRandomBase32String(BASE32_LINK_BYTES);
 
 		context.checking(new DbExpectations() {{
 			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
 			oneOf(identityManager).getHandshakeKeys(txn);
-			will(returnValue(keyPair));
-			oneOf(pendingContactFactory).createHandshakeLink(publicKey);
+			will(returnValue(handshakeKeyPair));
+			oneOf(pendingContactFactory).createHandshakeLink(
+					handshakeKeyPair.getPublic());
 			will(returnValue(link));
 		}});
 
 		assertEquals(link, contactManager.getHandshakeLink());
 	}
+
+	@Test
+	public void testDefaultPendingContactState() throws Exception {
+		Transaction txn = new Transaction(null, true);
+
+		context.checking(new DbExpectations() {{
+			oneOf(db).transactionWithResult(with(true), withDbCallable(txn));
+			oneOf(db).getPendingContacts(txn);
+			will(returnValue(singletonList(pendingContact)));
+		}});
+
+		// No events have happened for this pending contact, so the state
+		// should be WAITING_FOR_CONNECTION
+		Collection<Pair<PendingContact, PendingContactState>> pairs =
+				contactManager.getPendingContacts();
+		assertEquals(1, pairs.size());
+		Pair<PendingContact, PendingContactState> pair =
+				pairs.iterator().next();
+		assertEquals(pendingContact, pair.getFirst());
+		assertEquals(WAITING_FOR_CONNECTION, pair.getSecond());
+	}
+
+	@Test
+	public void testPendingContactExpiresBeforeConnection() {
+		// The pending contact expires - the FAILED state is broadcast
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == FAILED)));
+		}});
+		contactManager.eventOccurred(new RendezvousFailedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// A rendezvous connection is opened - no state is broadcast
+		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// The rendezvous connection fails - no state is broadcast
+		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
+
+	@Test
+	public void testPendingContactExpiresDuringFailedConnection() {
+		// A rendezvous connection is opened - the ADDING_CONTACT state is
+		// broadcast
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == ADDING_CONTACT)));
+		}});
+
+		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// The pending contact expires - the FAILED state is broadcast
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == FAILED)));
+		}});
+
+		contactManager.eventOccurred(new RendezvousFailedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// The rendezvous connection fails - no state is broadcast
+		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
+
+	@Test
+	public void testPendingContactExpiresDuringSuccessfulConnection()
+			throws Exception {
+		Transaction txn = new Transaction(null, false);
+
+		// A rendezvous connection is opened - the ADDING_CONTACT state is
+		// broadcast
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == ADDING_CONTACT)));
+		}});
+
+		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// The pending contact expires - the FAILED state is broadcast
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == FAILED)));
+		}});
+
+		contactManager.eventOccurred(new RendezvousFailedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// The pending contact is converted to a contact - no state is broadcast
+		context.checking(new DbExpectations() {{
+			oneOf(db).getPendingContact(txn, pendingContact.getId());
+			will(returnValue(pendingContact));
+			oneOf(db).removePendingContact(txn, pendingContact.getId());
+			oneOf(db).addContact(txn, remote, local,
+					pendingContact.getPublicKey(), verified);
+			will(returnValue(contactId));
+			oneOf(db).setContactAlias(txn, contactId,
+					pendingContact.getAlias());
+			oneOf(identityManager).getHandshakeKeys(txn);
+			will(returnValue(handshakeKeyPair));
+			oneOf(keyManager).addContact(txn, contactId,
+					pendingContact.getPublicKey(), handshakeKeyPair);
+			oneOf(keyManager).addRotationKeys(txn, contactId, rootKey,
+					timestamp, alice, active);
+			oneOf(db).getContact(txn, contactId);
+			will(returnValue(contact));
+		}});
+
+		contactManager.addContact(txn, pendingContact.getId(), remote,
+				local, rootKey, timestamp, alice, verified, active);
+		context.assertIsSatisfied();
+
+		// The rendezvous connection succeeds - no state is broadcast
+		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), true));
+	}
+
+	@Test
+	public void testPendingContactRemovedDuringFailedConnection()
+			throws Exception {
+		Transaction txn = new Transaction(null, false);
+
+		// A rendezvous connection is opened - the ADDING_CONTACT state is
+		// broadcast
+		context.checking(new Expectations() {{
+			oneOf(eventBus).broadcast(with(new PredicateMatcher<>(
+					PendingContactStateChangedEvent.class, e ->
+					e.getPendingContactState() == ADDING_CONTACT)));
+		}});
+
+		contactManager.eventOccurred(new RendezvousConnectionOpenedEvent(
+				pendingContact.getId()));
+		context.assertIsSatisfied();
+
+		// The pending contact is removed - no state is broadcast
+		context.checking(new DbExpectations() {{
+			oneOf(db).transaction(with(false), withDbRunnable(txn));
+			oneOf(db).removePendingContact(txn, pendingContact.getId());
+		}});
+
+		contactManager.removePendingContact(pendingContact.getId());
+		context.assertIsSatisfied();
+
+		// The rendezvous connection fails - no state is broadcast
+		contactManager.eventOccurred(new RendezvousConnectionClosedEvent(
+				pendingContact.getId(), false));
+	}
 }
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/PredicateMatcher.java b/bramble-core/src/test/java/org/briarproject/bramble/test/PredicateMatcher.java
new file mode 100644
index 0000000000000000000000000000000000000000..b08f15b2aa4ed470967a055fdfae26a951637ac4
--- /dev/null
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/PredicateMatcher.java
@@ -0,0 +1,28 @@
+package org.briarproject.bramble.test;
+
+import org.briarproject.bramble.api.Predicate;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+
+public class PredicateMatcher<T> extends BaseMatcher<T> {
+
+	private final Class<T> matchedClass;
+	private final Predicate<T> predicate;
+
+	public PredicateMatcher(Class<T> matchedClass, Predicate<T> predicate) {
+		this.matchedClass = matchedClass;
+		this.predicate = predicate;
+	}
+
+	@Override
+	public boolean matches(Object item) {
+		if (matchedClass.isInstance(item))
+			return predicate.test(matchedClass.cast(item));
+		return false;
+	}
+
+	@Override
+	public void describeTo(Description description) {
+		description.appendText("matches an item against a predicate");
+	}
+}