diff --git a/Makefile b/Makefile
index cefa712cda7524012c97004b48ddb1e62816fef6..2a216e1b47600ba21525b784257378a8271ba502 100644
--- a/Makefile
+++ b/Makefile
@@ -382,6 +382,14 @@ sc.db.rollback_n: ## run migrations
 	docker-compose -f docker-compose-sc.yml \
 	run -e SIGNALC_ENV=test --entrypoint 'gradle --console=plain rollbackCount -PliquibaseCommandValue=$(N)' signalc
 
+sc.db.clear_checksums: ## run migrations
+	echo "----- rolling back 1 development migration" && \
+	docker-compose -f docker-compose-sc.yml \
+	run -e SIGNALC_ENV=development --entrypoint 'gradle --console=plain clearCheckSums' signalc && \
+	echo "----- rolling back 1 test migration" && \
+	docker-compose -f docker-compose-sc.yml \
+	run -e SIGNALC_ENV=test --entrypoint 'gradle --console=plain clearCheckSums' signalc
+
 sc.db.psql: # get a psql shell on signalc db
 	./bin/sc/psql
 
diff --git a/docs/2-signals-on-1-device.md b/docs/2-signals-on-1-device.md
index ff589d5ffb48dbdf8c6c1c7e35d65bcfdd598cab..0b1b0ade90efbd7e055163a0a7227ea2ddcbaf3d 100644
--- a/docs/2-signals-on-1-device.md
+++ b/docs/2-signals-on-1-device.md
@@ -44,12 +44,14 @@ Create a new desktop entry file:
 sudo touch /usr/share/applications/signal-desktop-alice
 ```
 
-Edit that file to have the following contents
+Edit that file to have the following contents:
+
+(note -- it is necessary to provide the absolute path to your home directory)
 
 ```
 [Desktop Entry]
 Name=Signal-Alice
-Exec=/opt/Signal/signal-desktop --no-sandbox --user-data-dir=$HOME/.config/Signal-Alice %U
+Exec=/opt/Signal-Alice/signal-desktop --no-sandbox --user-data-dir=<absolute-path-to-$HOME>/.config/Signal-Alice %U
 Terminal=false
 Type=Application
 Icon=signal-desktop
diff --git a/signalc/migrations/changelog.postgresql.sql b/signalc/migrations/changelog.postgresql.sql
index 9e5aca8d4743aaaa417256566b06b63f98dcd09d..2da95ea918b858037d581a945a33930e3257a834 100644
--- a/signalc/migrations/changelog.postgresql.sql
+++ b/signalc/migrations/changelog.postgresql.sql
@@ -155,3 +155,111 @@ ALTER TABLE identities
 -- rollback ALTER TABLE identities
 -- rollback     DROP COLUMN created_at,
 -- rollback     DROP COLUMN updated_at;
+
+-- changeset aguestuser:1622741296513-1 failOnError:true
+CREATE TABLE IF NOT EXISTS contacts (
+    account_id VARCHAR(255),
+    contact_id SERIAL,
+    uuid uuid NULL,
+    phone_number VARCHAR(255) NOT NULL,
+    profile_key_bytes bytea NULL,
+    CONSTRAINT pk_Contacts PRIMARY KEY (contact_id, account_id)
+);
+CREATE INDEX contacts_account_id_uuid ON contacts (account_id, uuid);
+CREATE INDEX contacts_account_id_phone_number ON contacts (account_id, phone_number);
+-- rollback DROP TABLE contacts;
+
+
+-- changeset aguestuser:1623100049239-1 failOnError:true
+DELETE FROM accounts;
+DELETE FROM contacts;
+DELETE FROM identities;
+DELETE FROM ownidentities;
+DELETE FROM prekeys;
+DELETE FROM profiles;
+DELETE FROM senderkeys;
+DELETE FROM sessions;
+DELETE FROM signedprekeys;
+
+ALTER TABLE identities DROP CONSTRAINT pk_identities;
+ALTER TABLE identities DROP COLUMN contact_id;
+ALTER TABLE identities ADD COLUMN contact_id INT;
+ALTER TABLE identities ADD CONSTRAINT pk_identities PRIMARY KEY (account_id, contact_id);
+
+ALTER TABLE sessions DROP CONSTRAINT pk_sessions;
+ALTER TABLE sessions DROP COLUMN contact_id;
+ALTER TABLE sessions ADD COLUMN contact_id INT;
+ALTER TABLE sessions ADD CONSTRAINT pk_sessions PRIMARY KEY (account_id, contact_id, device_id);
+-- rollback DELETE FROM accounts;
+-- rollback DELETE FROM contacts;
+-- rollback DELETE FROM identities;
+-- rollback DELETE FROM ownidentities;
+-- rollback DELETE FROM prekeys;
+-- rollback DELETE FROM profiles;
+-- rollback DELETE FROM senderkeys;
+-- rollback DELETE FROM sessions;
+-- rollback DELETE FROM signedprekeys;
+-- rollback
+-- rollback ALTER TABLE identities DROP CONSTRAINT pk_identities;
+-- rollback ALTER TABLE identities DROP COLUMN contact_id;
+-- rollback ALTER TABLE identities ADD COLUMN contact_id VARCHAR(255) NOT NULL;
+-- rollback ALTER TABLE identities ADD CONSTRAINT pk_identities PRIMARY KEY (account_id, contact_id);
+-- rollback
+-- rollback ALTER TABLE sessions DROP CONSTRAINT pk_sessions;
+-- rollback ALTER TABLE sessions DROP COLUMN contact_id;
+-- rollback ALTER TABLE sessions ADD COLUMN contact_id VARCHAR(255) NOT NULL;
+-- rollback ALTER TABLE sessions ADD CONSTRAINT pk_sessions PRIMARY KEY (account_id, contact_id, device_id);
+
+-- changeset aguestuser:1623100049239-2 failOnError:true
+DROP INDEX identities_identity_key_bytes;
+-- rollback CREATE INDEX identities_identity_key_bytes ON identities (identity_key_bytes);
+
+
+-- changeset fdbk:1623280052786-1 failOnError:true
+ALTER TABLE contacts ALTER COLUMN phone_number DROP NOT NULL;
+-- rollback ALTER TABLE contacts ALTER COLUMN phone_number SET NOT NULL;
+
+-- changeset aguestuser:1624301616527-1 failOnError:true
+-- NOTE: This migration introduces uniqueness constraints that will fail if there are any duplicate account_id/uuid or
+-- account_id/phone_number combinations for any contact rows. Such dupes constitute an illegal state.
+-- They are associated with bugs that we have now eradicated from the code. However, we nevertheless
+-- begin this migration with 2 queries to clear all contacts having such an illegal state so that dev environments
+-- created before the bug was eradicated may successfully run this migration.
+-- Here we remove all contacts with duplicate account_id/uuid combos:
+with uuid_counts as (
+    select count(*), account_id, uuid from contacts
+    group by account_id, uuid
+    order by count(*)
+),
+uuid_dupes as (
+    select account_id, uuid from uuid_counts where uuid_counts.count > 1
+)
+delete from contacts where (account_id, uuid) in (select * from uuid_dupes);
+-- Here we remove all contacts with duplicate account_id/phone_number combos:
+with phone_number_counts as (
+    select count(*), account_id, phone_number from contacts
+    group by account_id, phone_number
+    order by count(*)
+),
+phone_number_dupes as (
+    select account_id, phone_number from phone_number_counts where phone_number_counts.count > 1
+)
+delete from contacts where (account_id, phone_number) in (select * from phone_number_dupes);
+-- And now we proceed to the migration!
+DROP INDEX contacts_account_id_uuid;
+CREATE UNIQUE INDEX contacts_account_id_uuid ON contacts (account_id, uuid);
+DROP INDEX contacts_account_id_phone_number;
+CREATE UNIQUE INDEX contacts_account_id_phone_number ON contacts (account_id, phone_number);
+-- rollback DROP INDEX contacts_account_id_uuid;
+-- rollback CREATE INDEX contacts_account_id_uuid ON contacts (account_id, uuid);
+-- rollback DROP INDEX contacts_account_id_phone_number;
+-- rollback CREATE INDEX contacts_account_id_phone_number ON contacts (account_id, phone_number);
+
+-- changeset aguestuser:1624301616527-2 failOnError:true
+ALTER TABLE contacts ALTER COLUMN phone_number DROP DEFAULT;
+-- rollback ALTER TABLE contacts ALTER COLUMN phone_number SET DEFAULT '';
+
+-- changeset aguestuser:1624301616527-3 failOnError:true
+ALTER TABLE contacts ADD CONSTRAINT phone_number_regex CHECK(phone_number ~ '^\+\d{9,15}\Z');
+-- rollback ALTER TABLE contacts DROP CONSTRAINT phone_number_regex;
+
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt
index 042d4c36b5ba2a7f32d274e7ed8d66ed9a1d3a04..9707f4f6546b09b299836ae42b743dc7dc8b34e3 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt
@@ -7,7 +7,7 @@ import info.signalboost.signalc.logging.LibSignalLogger
 import info.signalboost.signalc.logic.*
 import info.signalboost.signalc.metrics.Metrics
 import info.signalboost.signalc.store.AccountStore
-import info.signalboost.signalc.store.ProfileStore
+import info.signalboost.signalc.store.ContactStore
 import info.signalboost.signalc.store.ProtocolStore
 import io.mockk.coEvery
 import io.mockk.mockk
@@ -152,7 +152,7 @@ class Application(val config: Config.App){
     // STORE //
 
     lateinit var accountStore: AccountStore
-    lateinit var profileStore: ProfileStore
+    lateinit var contactStore: ContactStore
     lateinit var protocolStore: ProtocolStore
 
     private lateinit var dataSource: HikariDataSource
@@ -239,8 +239,8 @@ class Application(val config: Config.App){
 
         // storage resources
         dataSource = initializeDataSource(Mocks.dataSource)
-        accountStore = initializeColdComponent(AccountStore::class)
-        profileStore = initializeColdComponent(ProfileStore::class, Mocks.profileStore)
+        accountStore = initializeColdComponent(AccountStore::class, Mocks.accountStore)
+        contactStore = initializeColdComponent(ContactStore::class, Mocks.contactStore)
         protocolStore = initializeColdComponent(ProtocolStore::class, Mocks.protocolStore)
 
         // network resources
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt
index 5424c5442125759d71f8a328c8e2c67c93420f17..57dbb78765bba41877d8d0c597e5f0bbf7e80164 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt
@@ -4,7 +4,7 @@ import com.zaxxer.hikari.HikariDataSource
 import info.signalboost.signalc.logic.*
 import info.signalboost.signalc.metrics.Metrics
 import info.signalboost.signalc.store.AccountStore
-import info.signalboost.signalc.store.ProfileStore
+import info.signalboost.signalc.store.ContactStore
 import info.signalboost.signalc.store.ProtocolStore
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.ObsoleteCoroutinesApi
@@ -26,7 +26,7 @@ object Config {
         HikariDataSource::class,
         AccountStore::class,
         ProtocolStore::class,
-        ProfileStore::class,
+        ContactStore::class,
         // components
         AccountManager::class,
         SignalReceiver::class,
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt
index 177c7126a96bd5be1517d6ee6b01987b7b330751..dc5137de2d4a0e87ad0bb337a396381078dc7e0e 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt
@@ -3,9 +3,9 @@ package info.signalboost.signalc
 import com.zaxxer.hikari.HikariDataSource
 import info.signalboost.signalc.logic.*
 import info.signalboost.signalc.metrics.Metrics
-import info.signalboost.signalc.model.SignalcAddress
-import info.signalboost.signalc.model.SignalcSendResult
-import info.signalboost.signalc.store.ProfileStore
+import info.signalboost.signalc.model.*
+import info.signalboost.signalc.store.AccountStore
+import info.signalboost.signalc.store.ContactStore
 import info.signalboost.signalc.store.ProtocolStore
 import io.mockk.coEvery
 import io.mockk.every
@@ -29,14 +29,24 @@ object Mocks {
         coEvery { publishPreKeys(any()) } returns Unit
         coEvery { publishPreKeys(any(), any()) } returns Unit
         coEvery { refreshPreKeysIfDepleted(any()) } returns Unit
-        coEvery { getUnidentifiedAccessPair(any(), any()) } returns mockk()
+        coEvery { getUnidentifiedAccessPair(any(), any()) } returns null
+    }
+    val accountStore: AccountStore.() -> Unit = {
+        coEvery { save(any<NewAccount>()) } returns Unit
+        coEvery { save(any<RegisteredAccount>()) } returns Unit
+        coEvery { save(any<VerifiedAccount>()) } returns Unit
     }
     val dataSource: HikariDataSource.() -> Unit = {
         every { closeQuietly() } returns Unit
     }
-    val profileStore: ProfileStore.() -> Unit = {
-        coEvery { storeProfileKey(any(), any(), any())} returns Unit
+    val contactStore: ContactStore.() -> Unit = {
+        coEvery { create(any(), any()) } returns 0
+        coEvery { create(any(), any(), any(), any()) } returns 0
+        coEvery { createOwnContact(any()) } returns 0
+        coEvery { hasContact(any(), any()) } returns true
         coEvery { loadProfileKey(any(), any())} returns mockk()
+        coEvery { storeMissingIdentifier(any(), any(), any())} returns Unit
+        coEvery { storeProfileKey(any(), any(), any())} returns Unit
     }
     val protocolStore: ProtocolStore.() -> Unit = {
         every { of(any()) } returns mockk {
@@ -65,6 +75,12 @@ object Mocks {
         coEvery { send(any(), any(), any(), any(), any(), any()) } answers {
             mockkSuccessOf(secondArg())
         }
+        coEvery { sendProfileKey(any(), any(), any()) } answers {
+            mockkSuccessOf(secondArg())
+        }
+        coEvery { sendReceipt(any(), any(), any()) } answers {
+            mockkSuccessOf(secondArg())
+        }
         coEvery { setExpiration(any(), any(), any()) } answers {
             mockkSuccessOf(secondArg())
         }
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt
index bfa50aca3978fc8bacc8374f6db0391a9851f949..0d3e5fa173ddc482aa4948b9879b1935dbfe801a 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt
@@ -10,18 +10,18 @@ import org.jetbrains.exposed.sql.statements.UpdateStatement
 
 interface ContactRecord: FieldSet {
     val accountId: Column<String>
-    val contactId: Column<String>
+    val contactId: Column<Int>
 
     companion object {
 
-        fun ContactRecord.findByContactId(accountId: String, contactId: String): ResultRow? {
+        fun ContactRecord.findByContactId(accountId: String, contactId: Int): ResultRow? {
             val table = this
             return table.select {
                 (table.accountId eq accountId).and(table.contactId eq contactId)
             }.singleOrNull()
         }
 
-        fun ContactRecord.findManyByContactId(accountId: String, contactId: String): List<ResultRow> {
+        fun ContactRecord.findManyByContactId(accountId: String, contactId: Int): List<ResultRow> {
             val table = this
             return table.select {
                 (table.accountId eq accountId).and(table.contactId eq contactId)
@@ -30,7 +30,7 @@ interface ContactRecord: FieldSet {
 
         fun ContactRecord.updateByContactId(
             accountId: String,
-            contactId: String,
+            contactId: Int,
             updateStatement: Table.(UpdateStatement) -> Unit
         ): Int {
             val table = this
@@ -39,7 +39,7 @@ interface ContactRecord: FieldSet {
             }, null, updateStatement)
         }
 
-        fun ContactRecord.deleteByContactId(accountId: String, contactId: String): Int {
+        fun ContactRecord.deleteByContactId(accountId: String, contactId: Int): Int {
             val table = this
             return (table as Table).deleteWhere {
                 (table.accountId eq accountId).and(table.contactId eq contactId)
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/Contacts.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/Contacts.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f2cdce9e19c725d91961b0633c4aa1f01c5b6235
--- /dev/null
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/Contacts.kt
@@ -0,0 +1,18 @@
+package info.signalboost.signalc.db
+
+import org.jetbrains.exposed.sql.Table
+
+object Contacts: Table(), ContactRecord {
+    override val accountId = varchar("account_id", 255)
+    override val contactId = integer("contact_id").autoIncrement()
+    val uuid = uuid("uuid").nullable()
+    val phoneNumber = varchar("phone_number", 255).nullable()
+    val profileKeyBytes = binary("profile_key_bytes").nullable()
+
+    override val primaryKey = PrimaryKey(accountId, contactId)
+
+    init {
+        index(isUnique = true, accountId, phoneNumber)
+        index(isUnique = true, accountId, uuid)
+    }
+}
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt
index 24aa4f9bc40005bdd806c3b160b94da4898104f0..669cfe0affcc681a48ac8b86515ae5e9e35963b7 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt
@@ -2,43 +2,43 @@ package info.signalboost.signalc.db
 
 import org.jetbrains.exposed.sql.*
 import org.jetbrains.exposed.sql.statements.UpdateStatement
-import org.whispersystems.libsignal.SignalProtocolAddress
 
 interface DeviceRecord: FieldSet {
     val accountId: Column<String>
-    val contactId: Column<String>
+    val contactId: Column<Int>
     val deviceId: Column<Int>
 
     companion object {
 
-        fun DeviceRecord.findByAddress(accountId: String, address: SignalProtocolAddress): ResultRow? {
+        fun DeviceRecord.findByDeviceId(accountId: String, contactId: Int, deviceId: Int): ResultRow? {
             val table = this
             return table.select {
                 (table.accountId eq accountId)
-                    .and(table.contactId eq address.name)
-                    .and(table.deviceId eq address.deviceId)
+                    .and(table.contactId eq contactId)
+                    .and(table.deviceId eq deviceId)
             }.singleOrNull()
         }
 
-        fun DeviceRecord.updateByAddress(
+        fun DeviceRecord.updateByDeviceId(
             accountId: String,
-            address: SignalProtocolAddress,
+            contactId: Int,
+            deviceId: Int,
             updateStatement: Table.(UpdateStatement) -> Unit
         ): Int {
             val table = this
             return (table as Table).update ({
                 (table.accountId eq accountId)
-                    .and(table.contactId eq address.name)
-                    .and(table.deviceId eq address.deviceId)
+                    .and(table.contactId eq contactId)
+                    .and(table.deviceId eq deviceId)
             }, null, updateStatement)
         }
 
-        fun DeviceRecord.deleteByAddress(accountId: String, address: SignalProtocolAddress): Int {
+        fun DeviceRecord.deleteByDeviceId(accountId: String, contactId: Int, deviceId: Int): Int {
             val table = this
             return (table as Table).deleteWhere {
                 (table.accountId eq accountId)
-                    .and(table.contactId eq address.name)
-                    .and(table.deviceId eq address.deviceId)
+                    .and(table.contactId eq contactId)
+                    .and(table.deviceId eq deviceId)
             }
         }
     }
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt
index 4295ac292be2437fb68ef9e658e1273df6f50223..ac3b608d7a21018606ec8b4f2499698806a3a947 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt
@@ -9,7 +9,7 @@ object Identities: Table(), ContactRecord {
     private const val IDENTITY_KEY_BYTE_ARRAY_LENGTH = 33
 
     override val accountId = varchar("account_id", 255)
-    override val contactId = varchar("contact_id", 255)
+    override val contactId = integer("contact_id")
     val identityKeyBytes = binary("identity_key_bytes", IDENTITY_KEY_BYTE_ARRAY_LENGTH).index()
     val isTrusted = bool("is_trusted").default(true)
     val createdAt = timestamp("created_at").clientDefault { Instant.now() }
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt
index 4ca084f585dd39c2806690a2ed5e459a869c8b59..b0d859c9fd8968362365f145922ddf4a3ac98b23 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt
@@ -3,10 +3,9 @@ package info.signalboost.signalc.db
 import org.jetbrains.exposed.sql.Table
 
 
-
 object Sessions: Table(), ContactRecord, DeviceRecord  {
     override val accountId = varchar("account_id", 255)
-    override val contactId = varchar("contact_id", 255)
+    override val contactId = integer("contact_id")
     override val deviceId = integer("device_id")
     val sessionBytes = binary("session_bytes")
 
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt b/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt
index 5e911d9847867c13304809af7372270b0a1f76de..1ade8209d588c15ca80c5646b276833e4a589bbc 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt
@@ -8,9 +8,10 @@ object SignalcError {
     object RegistrationOfRegsisteredUser: Exception("Cannot register account that is already registered")
     object SubscriptionOfUnregisteredUser: Exception("Cannot subscribe to messages for unregistered account")
     class UpdateToNonExistentFingerprint(
-        contactId: String,
+        accountId: String,
+        contactId: Int,
         fingerprint: ByteArray,
-    ): Exception("Cannot update non-existent fingerprint ${fingerprint.toHex()} for contact $contactId")
+    ): Exception("Cannot update non-existent fingerprint ${fingerprint.toHex()} for contact $contactId of account $accountId")
     object UnsubscribeUnregisteredUser: Exception("Cannot unsubscribe to messages for unregistered account")
     object VerificationOfNewUser: Exception("Cannot verify a new (unregistered) account")
     object VerificationOfVerifiedUser: Exception("Cannot verify account that is already verified")
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt
index ef6c6bfeb9bd984868e18790635997acb5d839ac..628c09a7a6bf603df97936aeef676eadf9561dc2 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt
@@ -9,7 +9,10 @@ import info.signalboost.signalc.model.NewAccount
 import info.signalboost.signalc.model.RegisteredAccount
 import info.signalboost.signalc.model.VerifiedAccount
 import info.signalboost.signalc.util.KeyUtil
-import kotlinx.coroutines.*
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.ObsoleteCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withContext
 import mu.KLoggable
 import org.whispersystems.libsignal.util.guava.Optional
 import org.whispersystems.signalservice.api.SignalServiceAccountManager
@@ -20,9 +23,8 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE
 import org.whispersystems.signalservice.api.util.UptimeSleepTimer
 import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
 import java.io.IOException
-import java.util.UUID
+import java.util.*
 import kotlin.io.path.ExperimentalPathApi
-import kotlin.jvm.Throws
 import kotlin.time.ExperimentalTime
 
 
@@ -72,20 +74,20 @@ class AccountManager(private val app: Application) {
 
     // register an account with signal server and request an sms token to use to verify it (storing account in db)
     suspend fun register(account: NewAccount, captcha: String? = null): RegisteredAccount {
-        app.coroutineScope.async(Concurrency.Dispatcher) {
+        withContext(app.coroutineScope.coroutineContext + Concurrency.Dispatcher) {
             accountManagerOf(account).requestSmsVerificationCode(
                 false,
                 captcha?.let { Optional.of(it) } ?: Optional.absent(),
                 Optional.absent()
             )
-        }.await()
+        }
         return RegisteredAccount.fromNew(account).also { accountStore.save(it) }
     }
 
     // provide a verification code, retrieve and store a UUID (storing account in db when done)
     suspend fun verify(account: RegisteredAccount, code: String): VerifiedAccount? {
         val verifyResponse: VerifyAccountResponse = try {
-            app.coroutineScope.async(Concurrency.Dispatcher) {
+            withContext(app.coroutineScope.coroutineContext + Concurrency.Dispatcher) {
                 accountManagerOf(account).verifyAccountWithCode(
                     code,
                     null,
@@ -98,7 +100,7 @@ class AccountManager(private val app: Application) {
                     AccountAttributes.Capabilities(true, false, false, false),
                     true
                 )
-            }.await()
+            }
         } catch(e: AuthorizationFailedException) {
             return null
         }
@@ -106,11 +108,13 @@ class AccountManager(private val app: Application) {
         // TODO(aguestuser|2020-12-23):
         //  - as a privacy matter, we might eventually want to throw away phone numbers once we have a UUID
         //  - if so, consider udpating `accountId` in protocol store to this uuid at this point?
-        return VerifiedAccount.fromRegistered(account, uuid).also{ accountStore.save(it) }
+        return VerifiedAccount.fromRegistered(account, uuid).also{
+            accountStore.save(it)
+            app.contactStore.createOwnContact(it)
+        }
     }
 
 
-
     /**
      * generate prekeys, store them locally and publish them to signal
      **/
@@ -156,10 +160,10 @@ class AccountManager(private val app: Application) {
      **/
     @Throws(IOException::class) // if network call to retreive sender cert fails
     suspend fun getUnidentifiedAccessPair(accountId: String, contactId: String): UnidentifiedAccessPair? {
-        val contactAccessKey = app.profileStore.loadProfileKey(accountId, contactId)?.let {
+        val contactAccessKey = app.contactStore.loadProfileKey(accountId, contactId)?.let {
             UnidentifiedAccess.deriveAccessKeyFrom(it)
         } ?: run {
-            logger.error { "Could not derive delivery token for $contactId: no profile key found." }
+            logger.warn { "Could not derive delivery token for $contactId: no profile key found." }
             return null
         }
 
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt
index ecb25ba04f197e20d9abbf493b0694cf6137594e..dbbd3e866c3599f378427f6df5c03a1f73ee7ea7 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt
@@ -16,18 +16,19 @@ import info.signalboost.signalc.util.FileUtil.readToFile
 import kotlinx.coroutines.*
 import mu.KLoggable
 import org.postgresql.util.Base64
+import org.signal.libsignal.metadata.ProtocolException
 import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException
 import org.whispersystems.signalservice.api.SignalServiceMessagePipe
 import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
 import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
-import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
 import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope
 import org.whispersystems.signalservice.api.util.UptimeSleepTimer
 import java.io.File
 import java.io.IOException
 import java.nio.file.Files
+import java.util.*
 import java.util.concurrent.CancellationException
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.TimeUnit
@@ -44,6 +45,7 @@ import kotlin.time.ExperimentalTime
 class SignalReceiver(private val app: Application) {
     companion object: Any(), KLoggable {
         override val logger = logger()
+        private val metrics = Metrics.SignalReceiver
         private const val TMP_FILE_PREFIX = "___"
         private const val TMP_FILE_SUFFIX = ".tmp"
         private const val MAX_ATTACHMENT_SIZE = 150L * 1024 * 1024 // 150MB
@@ -60,7 +62,6 @@ class SignalReceiver(private val app: Application) {
     // FIELDS/FACTORIES
 
     internal val messagesInFlight = AtomicInteger(0)
-
     private val subscriptions = ConcurrentHashMap<String,Job>()
     private val messageReceivers = ConcurrentHashMap<String,SignalServiceMessageReceiver>()
     private val messagePipes = ConcurrentHashMap<String,SignalServiceMessagePipe>()
@@ -166,90 +167,128 @@ class SignalReceiver(private val app: Application) {
     private suspend fun dispatch(account: VerifiedAccount, envelope: SignalServiceEnvelope): Job?  {
         envelope.type.asEnum().let {
             logger.debug { "Got ${it.asString} from ${envelope.sourceIdentifier ?: "SEALED"} to ${account.username}" }
-            Metrics.SignalReceiver.numberOfMessagesReceived.labels(it.asString).inc()
+            metrics.numberOfMessagesReceived.labels(it.asString).inc()
             return when (it) {
+                EnvelopeType.CIPHERTEXT,
+                EnvelopeType.UNIDENTIFIED_SENDER -> {
+                    processMessage(envelope, account)
+                }
                 EnvelopeType.PREKEY_BUNDLE -> {
-                    relay(envelope, account)
-                    maybeRefreshPreKeys(account)
+                    processPreKeyBundle(envelope, account)
+                    processMessage(envelope, account)
+                }
+                EnvelopeType.RECEIPT -> {
+                    processReceipt(envelope, account)
+                    null
+                }
+                EnvelopeType.KEY_EXCHANGE, // TODO: handle KEY_EXCHANGE to process "reset secure session" events
+                EnvelopeType.UNKNOWN ->  {
+                    drop(envelope, account)
+                    null
                 }
-
-                EnvelopeType.UNIDENTIFIED_SENDER,
-                EnvelopeType.CIPHERTEXT -> relay(envelope, account)
-
-                EnvelopeType.KEY_EXCHANGE, // TODO: handle this to process "reset secure session" events
-                EnvelopeType.RECEIPT, // signal android basically drops these, so do we!
-                EnvelopeType.UNKNOWN -> drop(envelope, account)
             }
-
         }
     }
 
-    private suspend fun relay(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job {
-        // Attempt to decrypt envelope in a new coroutine then relay result to socket message sender for handling.
-        val (sender, recipient) = Pair(envelope.asSignalcAddress(), account.address)
+    /**
+     * Attempt to decrypt envelope and process data message then relay result to socket for handling by client.
+     */
+    private suspend fun processMessage(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job {
         return app.coroutineScope.launch(Concurrency.Dispatcher) {
+            var contactAddress: SignalcAddress? = null // not available until after decryption for sealed-sender msgs
             try {
                 messagesInFlight.getAndIncrement()
-                val dataMessage: SignalServiceDataMessage = cipherOf(account).decrypt(envelope).dataMessage.orNull()
-                    ?: return@launch // drop other message types (eg: typing message, sync message, etc)
 
-                val body = dataMessage.body?.orNull() ?: ""
-                val attachments = dataMessage.attachments.orNull() ?: emptyList()
+                // decrypt data message (returning early if not data message -- eg: typing notification, etc.)
+                val contents = cipherOf(account).decrypt(envelope)
+                val dataMessage = contents.dataMessage.orNull()?: return@launch
+                contactAddress = contents.sender.asSignalcAddress()
+
+                // store sender's profile key if present and acknowledge message receipt to them
                 dataMessage.profileKey.orNull()?.let {
-                    app.profileStore.storeProfileKey(recipient.identifier, sender.identifier, it)
+                    // TODO: we'd like to only make this db call if `dataMessage.isProfileKeyUpdate`, but that is flaky
+                    app.contactStore.storeProfileKey(account.id, contactAddress.identifier, it)
+                } ?: run {
+                    metrics.numberOfMessagesWithoutProfileKey.labels(envelope.isUnidentifiedSender.toString()).inc()
+                }
+                launch(Concurrency.Dispatcher) {
+                    app.signalSender.sendReceipt(account, contactAddress, dataMessage.timestamp)
                 }
 
+                // extract contents of message and relay to client
+                val body = dataMessage.body?.orNull() ?: "" // expiry timer changes contain empty message bodies
+                val attachments = dataMessage.attachments.orNull() ?: emptyList()
                 app.socketSender.send(
                     SocketResponse.Cleartext.of(
-                        sender,
-                        recipient,
-                        body, // we allow empty message bodies b/c that's how expiry timer changes are sent
+                        contactAddress,
+                        account.address,
+                        body,
                         attachments.mapNotNull { it.retrieveFor(account) },
                         dataMessage.expiresInSeconds,
                         dataMessage.timestamp,
                     )
                 )
             } catch(err: Throwable) {
-                handleRelayError(err, sender, recipient)
+                handleRelayError(err, account.address, contactAddress)
             } finally {
                 messagesInFlight.getAndDecrement()
             }
         }
     }
 
-    private fun maybeRefreshPreKeys(account: VerifiedAccount) = app.coroutineScope.launch(Concurrency.Dispatcher) {
-        // If we are receiving a prekey bundle, this is the beginning of a new session, the initiation
-        // of which might have depleted our prekey reserves below the level we want to keep on hand
-        // to start new sessions. So: launch a background job to check our prekey reserve and replenish it if needed!
-        app.accountManager.refreshPreKeysIfDepleted(account)
-    }
+    private suspend fun processPreKeyBundle(envelope: SignalServiceEnvelope, account: VerifiedAccount) =
+        withContext(Concurrency.Dispatcher) {
+            // Prekey bundles are received (once per device) from new contacts before we initiate a sealed sender session
+            // with them. As such, this is our first (and only!) chance to store both the UUID and phone number
+            // that will be necessary to successfully decrypt subsequent messages in a session that may refer to the same
+            // user by *either* UUID or phone number (and will switch between the two during thes same session).
+            // We store both here so we can resolve either UUID or phone number to the same contact later.
+            // We also want new contacts to be able to send us sealed sender messages as soon as possible, so
+            // we send them our profile key, which enables sealed-sender message sending.
+            if(!app.contactStore.hasContact(account.id, envelope.sourceIdentifier)) {
+                app.contactStore.create(account, envelope)
+                app.signalSender.sendProfileKey(account, envelope.asSignalcAddress())
+            }
+            // If we are receiving a prekey bundle, this is also the beginning of a new session, which consumes one of the
+            // recipient's one-time prekeys. Since this might have depleted the recipient's prekeys below the number needed
+            // to start new sessions, we launch a background job to check the prekey reserve and replenish it if needed!
+            app.accountManager.refreshPreKeysIfDepleted(account)
+        }
+
+    private suspend fun processReceipt(envelope: SignalServiceEnvelope, account: VerifiedAccount) =
+        // An unsealed receipt is the first contact we will have if we initiated a conversation with a contact, so
+        // use the opportunity to store their UUID and phone number! (Note: all envelopes of type RECEIPT are unsealed.)
+        app.contactStore.storeMissingIdentifier(
+            accountId = account.id,
+            contactPhoneNumber =  envelope.sourceE164.get(),
+            contactUuid =  UUID.fromString(envelope.sourceUuid.get()),
+        )
+
 
-    private suspend fun drop(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job? {
+    private suspend fun drop(envelope: SignalServiceEnvelope, account: VerifiedAccount) =
         app.socketSender.send(
             SocketResponse.Dropped(envelope.asSignalcAddress(), account.address, envelope)
         )
-        return null
-    }
 
-    private suspend fun handleRelayError(err: Throwable, sender: SignalcAddress, recipient: SignalcAddress) {
+    private suspend fun handleRelayError(err: Throwable, account: SignalcAddress, decryptedContact: SignalcAddress?) {
         when (err) {
             is ProtocolUntrustedIdentityException -> {
                 // When we get this exception we return a null fingerprint to the client, with the intention of causing
                 // it to send a garbage message that will force a session reset and raise another identity exception.
                 // That exception (unlike this one) will include the fingerprint corresponding to the new session,
                 // and can be handled (and trusted) from the send path.
-                app.socketSender.send(
-                    SocketResponse.InboundIdentityFailure.of(
-                        recipient,
-                        sender,
-                        null,
-                    )
-                )
+                val contactAddress = app.contactStore.getContactAddress(account.id, err.sender)
+                app.socketSender.send(SocketResponse.InboundIdentityFailure.of(account, contactAddress,null))
             }
-            else -> {
-                app.socketSender.send(SocketResponse.DecryptionError(sender, recipient, err))
+            is ProtocolException -> {
+                val contactAddress = app.contactStore.getContactAddress(account.id, err.sender)
+                app.socketSender.send(SocketResponse.DecryptionError(account, contactAddress, err))
                 logger.error { "Decryption Error:\n ${err.stackTraceToString()}" }
             }
+            else -> {
+                app.socketSender.send(SocketResponse.MessageHandlingError(account, decryptedContact, err))
+                logger.error { "Error handling incoming message from signal:\n ${err.stackTraceToString()}" }
+            }
         }
     }
 
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt
index e1e2a962d2ca14511c4d3d8a7628fb4b1a178418..007a5b67a7da394c91b415b64bae00088365a09b 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt
@@ -10,6 +10,7 @@ import info.signalboost.signalc.model.SocketRequest
 import info.signalboost.signalc.model.VerifiedAccount
 import info.signalboost.signalc.util.CacheUtil.getMemoized
 import info.signalboost.signalc.util.TimeUtil
+import info.signalboost.signalc.util.TimeUtil.nowInMillis
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.ObsoleteCoroutinesApi
 import kotlinx.coroutines.async
@@ -20,10 +21,12 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
+import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage
 import java.io.File
 import java.io.IOException
 import java.io.InputStream
 import java.nio.file.Files
+import java.time.Instant
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.atomic.AtomicInteger
 import kotlin.io.path.ExperimentalPathApi
@@ -81,7 +84,7 @@ class SignalSender(private val app: Application) {
                 Optional.absent(), // preview (we don't support those!)
                 sendAttachment.width,
                 sendAttachment.height,
-                TimeUtil.nowInMillis(), //uploadTimestamp
+                nowInMillis(), //uploadTimestamp
                 Optional.fromNullable(sendAttachment.caption),
                 Optional.fromNullable(sendAttachment.blurHash),
                 null, // progressListener
@@ -100,7 +103,7 @@ class SignalSender(private val app: Application) {
         body: String,
         expiration: Int,
         attachments: List<SocketRequest.Send.Attachment> = emptyList(),
-        timestamp: Long = TimeUtil.nowInMillis(),
+        timestamp: Long = nowInMillis(),
     ): SignalcSendResult =
         // TODO: handle `signalservice.api.push.exceptions.NotFoundException` here
         sendDataMessage(
@@ -118,6 +121,30 @@ class SignalSender(private val app: Application) {
                 .build()
         )
 
+    suspend fun sendProfileKey(
+        sender: VerifiedAccount,
+        recipient: SignalcAddress,
+        timestamp: Long = nowInMillis(),
+    ): SignalcSendResult =
+        sendDataMessage(
+            sender,
+            recipient,
+            SignalServiceDataMessage
+                .newBuilder()
+                .asProfileKeyUpdate(true)
+                .withTimestamp(timestamp)
+                .withProfileKey(sender.profileKeyBytes)
+                .build()
+        )
+
+    /* send a read receipt (so sender sees "2 checks" -- but only if we can send it as a sealed sender message */
+    suspend fun sendReceipt(sender: VerifiedAccount, recipient: SignalcAddress, timestamp: Long) =
+        messageSenderOf(sender).sendReceipt(
+            recipient.asSignalServiceAddress(),
+            Optional.fromNullable(app.accountManager.getUnidentifiedAccessPair(sender.id, recipient.identifier)),
+            SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, listOf(timestamp), timestamp),
+        )
+
     suspend fun setExpiration(
         sender: VerifiedAccount,
         recipient: SignalcAddress,
@@ -153,8 +180,8 @@ class SignalSender(private val app: Application) {
         // Try to send all messages sealed-sender by deriving `unidentifiedAccessPair`s from profile keys. Without such
         // tokens, message are sent unsealed, and are treated by Signal as spam. To avoid being blocked by Signal,
         // we expose a toggle to prevent all unsealed messages from being sent.
-        val unidentifiedAccessPair = app.accountManager.getUnidentifiedAccessPair(sender.identifier,recipient.identifier).also {
-            metrics.numberOfUnsealedMessagesProduced.labels(sender.identifier).inc()
+        val unidentifiedAccessPair = app.accountManager.getUnidentifiedAccessPair(sender.id, recipient.identifier).also {
+            metrics.numberOfUnsealedMessagesProduced.labels(sender.id).inc()
             if(it == null && app.toggles.blockUnsealedMessages) return@async SignalcSendResult.Blocked(recipient)
         }
         try {
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt b/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt
index 0353ef7d902590a67eb65cc30816209654b84ab6..a9eec56d5a2c4a4329df6555d288c3e3f461148a 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt
@@ -64,10 +64,20 @@ object Metrics {
     object SignalReceiver {
         val numberOfMessagesReceived: Counter = counterOf(
             "signal_receiver__number_of_messages_received",
-            "Counts number of inbound PREKEY_BUNDLE messages we receive from signal server when users try to establish new sessions." +
-                    "If we often receive a high number of these in quick succession, consider throttling prekey replenish jobs.",
+            "Counts number of inbound messages received from signal server and categorizes them by type",
             "envelope_type",
         )
+
+        val numberOfMessagesWithoutProfileKey = counterOf(
+            "signal_receiver__number_of_messages_without_profile_key",
+            "Counts number of messages received without profile key. " +
+                    "The 'is_sealed_sender' label allow us to track unsealed messages without profile keys " +
+                    "which are worrisome because this is the signature of sessions initiated from a desktop client " +
+                    "but not confirmed on a phone by tapping 'continue'. Without such confirmation, we will " +
+                    "never be able to initiate sealed sender sessions, and thus risk getting blocked " +
+                    "if the number of such messages gets too high.",
+            "is_sealed_sender"
+        )
     }
 
     object SignalSender {
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt b/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt
index 645b0a31637ee1b6ba607df18e7dd211fed5e10f..cb71f6bfce948836b7242682ed542bb9e282b83f 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt
@@ -196,6 +196,11 @@ data class VerifiedAccount(
         }
     }
 
+    // accessor that returns what signalboost (currently) uses: a phone number
+    val id: String
+      get() = username
+
+    // accessor that returns what signalservice address would return: a uuid
     val identifier: String
       get() = uuid.toString()
 
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt b/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt
index 339ae0cad8ad91b3b725cf70862e2b7c84cf2ccf..abf383d903d5c1b28f39e2c12235e059186324a9 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt
@@ -34,6 +34,10 @@ data class SignalcAddress(
             }
         }
     }
+
+    val id: String
+       get() = number!!
+
     val identifier: String
         get() = uuid?.toString() ?: number!!
 
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt b/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt
index 1d8f8033cb43486b20df74e13e34e5f804e43e79..d260cf930caad2c6b055cf37df60a2b7cd99d983 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt
@@ -108,12 +108,12 @@ sealed class SocketResponse {
         }
     }
 
-    // TODO: what does signald do here?
     @Serializable
     @SerialName("decryption_error")
     data class DecryptionError(
-        val sender: SignalcAddress,
         val recipient: SignalcAddress,
+        @Required
+        val sender: SignalcAddress?,
         @Serializable(ThrowableSerializer::class)
         val error: Throwable,
     ): SocketResponse()
@@ -130,7 +130,8 @@ sealed class SocketResponse {
         @Serializable
         data class Data(
             val local_address: LocalAddress,
-            val remote_address: RemoteAddress,
+            @Required
+            val remote_address: RemoteAddress?,
             @Required
             val fingerprint: String?
         )
@@ -144,11 +145,27 @@ sealed class SocketResponse {
         data class RemoteAddress(val number: String)
 
         companion object {
-            fun of(localAddress: SignalcAddress, remoteAddress: SignalcAddress, fingerprint: String? = null) =
-                InboundIdentityFailure(Data(LocalAddress(localAddress.number!!), RemoteAddress(remoteAddress.number!!), fingerprint))
+            fun of(localAddress: SignalcAddress, remoteAddress: SignalcAddress?, fingerprint: String? = null) =
+                InboundIdentityFailure(
+                    Data(
+                        LocalAddress(localAddress.number!!),
+                        remoteAddress?.let{ RemoteAddress(it.number!!) },
+                        fingerprint
+                    )
+                )
         }
     }
 
+    @Serializable
+    @SerialName("message_handling_error")
+    data class MessageHandlingError(
+        val recipient: SignalcAddress,
+        @Required
+        val sender: SignalcAddress?,
+        @Serializable(ThrowableSerializer::class)
+        val error: Throwable,
+    ): SocketResponse()
+
     @Serializable
     @SerialName("registration_succeeded")
     data class RegistrationSuccess(
@@ -197,7 +214,6 @@ sealed class SocketResponse {
         }
     }
 
-    // TODO: what does signald do here? "unrecognized"?
     @Serializable
     @SerialName("request_invalid") // TODO: invalidRequest
     data class RequestInvalidError(
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7ab8354316e732bdcfb760b893c44beee209ed65
--- /dev/null
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt
@@ -0,0 +1,173 @@
+package info.signalboost.signalc.store
+
+import info.signalboost.signalc.Application
+import info.signalboost.signalc.db.ContactRecord.Companion.findByContactId
+import info.signalboost.signalc.db.ContactRecord.Companion.updateByContactId
+import info.signalboost.signalc.db.Contacts
+import info.signalboost.signalc.dispatchers.Concurrency
+import info.signalboost.signalc.model.SignalcAddress
+import info.signalboost.signalc.model.VerifiedAccount
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.ObsoleteCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import mu.KLoggable
+import org.jetbrains.exposed.sql.*
+import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
+import org.jetbrains.exposed.sql.transactions.transaction
+import org.signal.zkgroup.profiles.ProfileKey
+import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope
+import java.lang.IllegalStateException
+import java.util.UUID
+import kotlin.io.path.ExperimentalPathApi
+import kotlin.time.ExperimentalTime
+
+@ExperimentalCoroutinesApi
+@ObsoleteCoroutinesApi
+@ExperimentalPathApi
+@ExperimentalTime
+class ContactStore(app: Application) {
+
+    companion object: Any(), KLoggable {
+        override val logger = logger()
+        private const val VALID_PROFILE_KEY_SIZE = 32
+    }
+
+    val dispatcher = Concurrency.Dispatcher
+    val db = app.db
+
+    suspend fun create(accountId: String, phoneNumber: String?, uuid: UUID?, profileKey: ByteArray? = null): Int =
+        if (phoneNumber == null && uuid == null)
+            throw IllegalStateException("Cannot create contact w/o phone number or uuid")
+        else newSuspendedTransaction(dispatcher, db) {
+            Contacts.insert {
+                it[Contacts.accountId] = accountId
+                it[Contacts.phoneNumber] = phoneNumber
+                it[Contacts.uuid] = uuid
+                it[profileKeyBytes] = profileKey
+            }
+        }.resultedValues!!.single()[Contacts.contactId]
+
+    suspend fun create(account: VerifiedAccount, envelope: SignalServiceEnvelope): Int =
+        create(account.id, envelope.sourceE164.orNull(), envelope.sourceUuid.orNull()?.let { UUID.fromString(it) } )
+
+    suspend fun createOwnContact(account: VerifiedAccount) = with(account) {
+        create(accountId = username, phoneNumber = username, uuid = uuid, profileKey = profileKeyBytes)
+    }
+
+    /**
+     * Given a string identifier that may be either a uuid or a phone number,
+     * Report whether a contact exists containing either that uuid or phone number.
+     */
+    suspend fun hasContact(accountId: String, contactIdentifier: String): Boolean =
+        newSuspendedTransaction(Concurrency.Dispatcher, db) {
+            Contacts.select {
+                (Contacts.accountId eq accountId).and(
+                    (Contacts.phoneNumber eq contactIdentifier).or(Contacts.uuid eq parseUuid(contactIdentifier))
+                )
+            }.count() > 0
+        }
+
+    /**
+     * Given a string identifier that may be either a uuid or a phone number,
+     * Retrieve a contact having either such identifier and convert it into a SignalcAddress
+     */
+    suspend fun getContactAddress(accountId: String, contactIdentifier: String): SignalcAddress? =
+        newSuspendedTransaction(Concurrency.Dispatcher, db) {
+            Contacts.select {
+                (Contacts.accountId eq accountId).and(
+                    (Contacts.uuid eq parseUuid(contactIdentifier)).or(Contacts.phoneNumber eq contactIdentifier)
+                )
+            }.singleOrNull()
+        }?.let { SignalcAddress(uuid = it[Contacts.uuid], number = it[Contacts.phoneNumber]) }
+
+    /**
+     * Given a string identifier -- which may be either a uuid or a phone number -- retrieve the contact having
+     * that uuid or phone number if it exists or create a new contact if it does not exist.
+     * This function accepts a Transaction as an argument so that it may be called from either a blocking or
+     * suspend contexts. Since most callers will be signal protocol store functions (which are all blocking),
+     * the argument defaults to a blocking transaction.
+     **/
+    fun resolveContactId(accountId: String, contactIdentifier: String, tx: Transaction? = null): Int {
+        val uuid = parseUuid(contactIdentifier)
+        val statement = {
+            run {
+                uuid
+                    ?.let { Contacts.select { (Contacts.accountId eq accountId).and(Contacts.uuid eq it) } }
+                    ?: Contacts.select { (Contacts.accountId eq accountId).and(Contacts.phoneNumber eq contactIdentifier) }
+            }.singleOrNull()
+                ?.let { it[Contacts.contactId] }
+                ?: runBlocking {
+                    if (uuid == null) create(accountId, contactIdentifier, null)
+                    else create(accountId, null, uuid)
+                }
+        }
+        return tx?.let  { statement() } ?: transaction(db) {  statement() }
+    }
+
+   /**
+    * Given a uuid and a phone number, store the uuid if we already have the phone number, or the phone number
+    * if we already have the uuid. Since most contacts start with a phone number, attempt to store the uuid first
+    * and return early if we succeed. Throw if we cannot find a contact with either uuid or phone number.
+    **/
+    suspend fun storeMissingIdentifier(accountId: String, contactPhoneNumber: String, contactUuid: UUID) {
+        newSuspendedTransaction(dispatcher, db) {
+
+            Contacts.update({
+                (Contacts.accountId eq accountId).and(Contacts.phoneNumber eq contactPhoneNumber)
+            }){
+                it[uuid] = contactUuid
+            }.let {
+                if(it > 0) return@newSuspendedTransaction
+            }
+
+            Contacts.update({
+                (Contacts.accountId eq accountId).and(Contacts.uuid eq contactUuid)
+            }){
+                it[phoneNumber] = contactPhoneNumber
+            }.let {
+                if(it > 0) return@newSuspendedTransaction
+            }
+
+            throw IllegalStateException(
+                "Can't store missing identifier without a known identifier- PN: $contactPhoneNumber, UUID: $contactUuid"
+            )
+        }
+    }
+
+    suspend fun storeProfileKey(accountId: String, contactIdentifier: String, profileKey: ByteArray) {
+        if (profileKey.size != VALID_PROFILE_KEY_SIZE) {
+            logger.warn { "Received profile key of invalid size ${profileKey.size} for account: $contactIdentifier" }
+            return
+        }
+        return newSuspendedTransaction(dispatcher, db) {
+            val contactId = resolveContactId(accountId, contactIdentifier, this)
+            Contacts.updateByContactId(accountId, contactId) {
+                it[Contacts.profileKeyBytes] = profileKey
+            }
+        }
+    }
+
+    suspend fun loadProfileKey(accountId: String, contactIdentifier: String): ProfileKey? =
+        newSuspendedTransaction(dispatcher, db) {
+            val contactId = resolveContactId(accountId, contactIdentifier, this)
+            Contacts.findByContactId(accountId, contactId)?.let { resultRow ->
+                resultRow[Contacts.profileKeyBytes]?.let{ ProfileKey(it) }
+            }
+        }
+
+    // HELPERS
+
+    private fun parseUuid(identifier: String): UUID? =
+        try { UUID.fromString(identifier) } catch(ignored: Throwable) { null }
+
+    // TESTING FUNCTIONS
+    internal suspend fun count(): Long = newSuspendedTransaction(dispatcher, db) {
+        Contacts.selectAll().count()
+    }
+
+    internal suspend fun deleteAllFor(accountId: String) = newSuspendedTransaction(dispatcher, db) {
+        Contacts.deleteWhere {
+            Contacts.accountId eq accountId
+        }
+    }
+}
\ No newline at end of file
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProfileStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ProfileStore.kt
deleted file mode 100644
index b45a451501b4a8691aa9119b5f43bd158ffd2a2c..0000000000000000000000000000000000000000
--- a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProfileStore.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package info.signalboost.signalc.store
-
-import info.signalboost.signalc.Application
-import info.signalboost.signalc.db.Profiles
-import info.signalboost.signalc.dispatchers.Concurrency
-import info.signalboost.signalc.serialization.ByteArrayEncoding.toPostgresHex
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.ObsoleteCoroutinesApi
-import mu.KLoggable
-import org.jetbrains.exposed.sql.*
-import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
-import org.signal.zkgroup.profiles.ProfileKey
-import kotlin.io.path.ExperimentalPathApi
-import kotlin.time.ExperimentalTime
-
-@ExperimentalCoroutinesApi
-@ObsoleteCoroutinesApi
-@ExperimentalPathApi
-@ExperimentalTime
-class ProfileStore(app: Application) {
-    companion object: Any(), KLoggable {
-        override val logger = logger()
-        private const val VALID_PROFILE_KEY_SIZE = 32
-    }
-
-    val dispatcher = Concurrency.Dispatcher
-    val db = app.db
-
-    suspend fun storeProfileKey(accountId: String, contactId: String, profileKey: ByteArray): Unit {
-        if (profileKey.size != VALID_PROFILE_KEY_SIZE) {
-            logger.warn { "Received profile key of invalid size ${profileKey.size} for account: $accountId" }
-            return
-        }
-        return newSuspendedTransaction(dispatcher, db) {
-            exec(
-                "INSERT into profiles (account_id, contact_id, profile_key_bytes) " +
-                        "VALUES('$accountId','$contactId', ${profileKey.toPostgresHex()}) " +
-                        "ON CONFLICT (account_id, contact_id)" +
-                        "DO UPDATE set profile_key_bytes = EXCLUDED.profile_key_bytes;"
-            )
-        }
-    }
-
-    suspend fun loadProfileKey(accountId: String, contactId: String): ProfileKey? =
-        newSuspendedTransaction(dispatcher, db) {
-            Profiles.select {
-                (Profiles.accountId eq accountId).and(
-                    Profiles.contactId eq contactId
-                )
-            }.singleOrNull()?.let {
-                ProfileKey(it[Profiles.profileKeyBytes])
-            }
-        }
-
-    // TESTING FUNCTIONS
-    internal suspend fun count(): Long = newSuspendedTransaction(dispatcher, db) {
-        Profiles.selectAll().count()
-    }
-
-    internal suspend fun deleteAllFor(accountId: String) = newSuspendedTransaction(dispatcher, db) {
-        Profiles.deleteWhere {
-            Profiles.accountId eq accountId
-        }
-    }
-
-
-}
\ No newline at end of file
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt
index 4e0573e8bd4dc77a119a1963bc2b8096299556fa..88c6426542ba4b6d5336cc8e4f9622953cb1d3da 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt
@@ -14,7 +14,6 @@ import org.whispersystems.libsignal.groups.state.SenderKeyStore
 import org.whispersystems.libsignal.state.*
 import org.whispersystems.signalservice.api.SignalServiceProtocolStore
 import org.whispersystems.signalservice.api.SignalServiceSessionStore
-import org.whispersystems.signalservice.api.push.SignalServiceAddress
 import java.time.Instant
 import kotlin.io.path.ExperimentalPathApi
 import kotlin.time.ExperimentalTime
@@ -24,21 +23,31 @@ import kotlin.time.ExperimentalTime
 @ObsoleteCoroutinesApi
 @ExperimentalPathApi
 @ExperimentalTime
-class ProtocolStore(app: Application) {
+class ProtocolStore(val app: Application) {
     val db = app.db
-    fun of(account: Account): AccountProtocolStore = AccountProtocolStore(db, account.username)
+    fun of(account: Account): AccountProtocolStore = AccountProtocolStore(
+        db,
+        account.username,
+        app.contactStore::resolveContactId
+    )
 
     fun countOwnIdentities(): Long =
         transaction(db) { OwnIdentities.selectAll().count() }
 
+    /**
+     * Implements the 4 stores required by the SignalServiceProtocolSTore interface and some decorator functions used
+     * only by signalc. Each instance of this class provides a protocol store for a single account. Since any instance
+     * of signalc has many accounts, any singalc instance will also have many protocol stores.
+     **/
     class AccountProtocolStore(
         private val db: Database,
         private val accountId: String,
+        private val resolveContactId: (String, String) -> Int,
         val lock: SessionLock = SessionLock(),
-        private val identityStore: IdentityKeyStore = SignalcIdentityStore(db, accountId, lock),
+        private val identityStore: IdentityKeyStore = SignalcIdentityStore(db, accountId, lock, resolveContactId),
         private val preKeyStore: PreKeyStore = SignalcPreKeyStore(db, accountId, lock),
         private val senderKeyStore: SenderKeyStore = SignalcSenderKeyStore(db, accountId, lock),
-        private val sessionStore: SignalServiceSessionStore = SignalcSessionStore(db, accountId, lock),
+        private val sessionStore: SignalServiceSessionStore = SignalcSessionStore(db, accountId, lock, resolveContactId),
         private val signedPreKeyStore: SignedPreKeyStore = SignalcSignedPreKeyStore(db, accountId, lock),
     ) : SignalServiceProtocolStore,
         IdentityKeyStore by identityStore,
@@ -47,9 +56,7 @@ class ProtocolStore(app: Application) {
         SignalServiceSessionStore by sessionStore,
         SignedPreKeyStore by signedPreKeyStore {
 
-        /**
-         * DECORATOR FUNCTIONS
-         **/
+        // DECORATOR FUNCTIONS
 
         private val scIdentityStore = identityStore as SignalcIdentityStore
         val countIdentitities: () -> Long = scIdentityStore::countIdentities
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt
index 19f55a2e54f679af2207843f61a496db96e47c47..6827e049e81c64afe80d44c94aea9abe5132610b 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt
@@ -25,6 +25,7 @@ class SignalcIdentityStore(
     val db: Database,
     val accountId: String,
     val lock: SessionLock,
+    val resolveContactId: (String, String) -> Int,
 ): IdentityKeyStore{
 
     override fun getIdentityKeyPair(): IdentityKeyPair =
@@ -57,8 +58,9 @@ class SignalcIdentityStore(
     @Throws(SignalcError.UpdateToNonExistentFingerprint::class)
     fun trustFingerprint(address: SignalProtocolAddress, fingerprint: ByteArray) {
         lock.acquireForTransaction(db) {
-            rejectUnknownFingerprint(address, fingerprint)
-            Identities.updateByContactId(accountId, address.name) {
+            val contactId = resolveContactId(accountId, address.name)
+            rejectUnknownFingerprint(contactId, fingerprint)
+            Identities.updateByContactId(accountId, contactId) {
                 it[isTrusted] = true
                 it[updatedAt] = Instant.now()
             }
@@ -68,22 +70,22 @@ class SignalcIdentityStore(
     @Throws(SignalcError.UpdateToNonExistentFingerprint::class)
     fun untrustFingerprint(address: SignalProtocolAddress, fingerprint: ByteArray) {
         lock.acquireForTransaction(db) {
-            rejectUnknownFingerprint(address, fingerprint)
-            Identities.updateByContactId(accountId, address.name) {
+            val contactId = resolveContactId(accountId, address.name)
+            rejectUnknownFingerprint(contactId, fingerprint)
+            Identities.updateByContactId(accountId, contactId) {
                 it[isTrusted] = false
                 it[updatedAt] = Instant.now()
             }
         }
     }
 
-    private fun rejectUnknownFingerprint(address: SignalProtocolAddress, fingerprint: ByteArray) {
+    private fun rejectUnknownFingerprint(contactId: Int, fingerprint: ByteArray) {
         transaction(db) {
-            val contactId = address.name
             val knownFingerprint = Identities.findByContactId(accountId, contactId)?.let {
                 it[identityKeyBytes]
             }
             if (!fingerprint.contentEquals(knownFingerprint))
-                throw SignalcError.UpdateToNonExistentFingerprint(contactId, fingerprint)
+                throw SignalcError.UpdateToNonExistentFingerprint(accountId, contactId, fingerprint)
         }
     }
 
@@ -93,7 +95,7 @@ class SignalcIdentityStore(
         // - deny trust for subsequent identity keys for same address
         // Returns true if this save was an update to an existing record, false otherwise
         lock.acquireForTransaction(db) {
-            val contactId = address.name
+            val contactId = resolveContactId(accountId, address.name)
             Identities.findByContactId(accountId, contactId)
                 ?.let { existingKey ->
                     Identities.updateByContactId(accountId, contactId) {
@@ -121,7 +123,7 @@ class SignalcIdentityStore(
     ): Boolean =
         lock.acquireForTransaction(db) {
             // trust a key if...
-            Identities.findByContactId(accountId, address.name)?.let {
+            Identities.findByContactId(accountId, resolveContactId(accountId, address.name))?.let {
                 // it matches a key we have seen before
                 it[identityKeyBytes] contentEquals identityKey.serialize() &&
                         // and we have not flagged it as untrusted
@@ -131,14 +133,14 @@ class SignalcIdentityStore(
 
     override fun getIdentity(address: SignalProtocolAddress): IdentityKey? =
         lock.acquireForTransaction(db) {
-            Identities.findByContactId(accountId, address.name)?.let {
+            Identities.findByContactId(accountId, resolveContactId(accountId, address.name))?.let {
                 IdentityKey(it[identityKeyBytes], 0)
             }
         }
 
     fun removeIdentity(address: SignalProtocolAddress) {
         lock.acquireForTransaction(db) {
-            Identities.deleteByContactId(accountId, address.name)
+            Identities.deleteByContactId(accountId, resolveContactId(accountId, address.name))
         }
     }
 
@@ -158,8 +160,7 @@ class SignalcIdentityStore(
 
     fun whenIdentityLastUpdated(address: SignalProtocolAddress): Instant? =
         transaction(db) {
-            val contactId = address.name
-            Identities.findByContactId(accountId, contactId)?.let {
+            Identities.findByContactId(accountId, resolveContactId(accountId, address.name))?.let {
                 it[updatedAt]
             }
         }
diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt
index d09a1d2ad695c49030f09de889c7d5ac57c837ed..4423a5fd3b3684a49ed88aeb1267ca07868d6b59 100644
--- a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt
+++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt
@@ -1,10 +1,11 @@
 package info.signalboost.signalc.store.protocol
 
 import info.signalboost.signalc.db.*
+import info.signalboost.signalc.db.ContactRecord.Companion.deleteByContactId
 import info.signalboost.signalc.db.ContactRecord.Companion.findManyByContactId
-import info.signalboost.signalc.db.DeviceRecord.Companion.deleteByAddress
-import info.signalboost.signalc.db.DeviceRecord.Companion.findByAddress
-import info.signalboost.signalc.db.DeviceRecord.Companion.updateByAddress
+import info.signalboost.signalc.db.DeviceRecord.Companion.deleteByDeviceId
+import info.signalboost.signalc.db.DeviceRecord.Companion.findByDeviceId
+import info.signalboost.signalc.db.DeviceRecord.Companion.updateByDeviceId
 import info.signalboost.signalc.db.Sessions.sessionBytes
 import org.jetbrains.exposed.sql.*
 import org.whispersystems.libsignal.SignalProtocolAddress
@@ -16,11 +17,13 @@ class SignalcSessionStore(
     val db: Database,
     val accountId: String,
     val lock: SessionLock,
+    val resolveContactId: (String, String) -> Int,
 ): SignalServiceSessionStore {
 
     override fun loadSession(address: SignalProtocolAddress): SessionRecord =
         lock.acquireForTransaction(db) {
-            Sessions.findByAddress(accountId, address)?.let {
+            val contactId = resolveContactId(accountId, address.name)
+            Sessions.findByDeviceId(accountId, contactId, address.deviceId)?.let {
                 SessionRecord(it[sessionBytes])
             } ?: SessionRecord()
         }
@@ -28,34 +31,35 @@ class SignalcSessionStore(
     override fun getSubDeviceSessions(name: String): MutableList<Int> =
         lock.acquireForTransaction(db) {
             Sessions.select {
-                Sessions.accountId eq accountId and (Sessions.contactId eq name)
+                (Sessions.accountId eq accountId).and(Sessions.contactId eq resolveContactId(accountId, name))
             }.mapTo(mutableListOf()) { it[Sessions.deviceId] }
         }
 
     override fun storeSession(address: SignalProtocolAddress, record: SessionRecord) {
         // upsert the session record for a given address
         lock.acquireForTransaction(db) {
-                Sessions.updateByAddress(accountId, address) {
-                    it[sessionBytes] = record.serialize()
-                }.let { numUpdated ->
-                    if (numUpdated == 0) {
-                        Sessions.insert {
-                            it[accountId] = this@SignalcSessionStore.accountId
-                            it[contactId] = address.name
-                            it[deviceId] = address.deviceId
-                            it[sessionBytes] = record.serialize()
-                        }
+            val contactId = resolveContactId(accountId, address.name)
+            Sessions.updateByDeviceId(accountId, contactId, address.deviceId) {
+                it[sessionBytes] = record.serialize()
+            }.let { numUpdated ->
+                if (numUpdated == 0) {
+                    Sessions.insert {
+                        it[accountId] = this@SignalcSessionStore.accountId
+                        it[Sessions.contactId] = contactId
+                        it[deviceId] = address.deviceId
+                        it[sessionBytes] = record.serialize()
                     }
                 }
             }
         }
+    }
 
     override fun containsSession(address: SignalProtocolAddress): Boolean =
         lock.acquireForTransaction(db) {
-            Sessions.findByAddress(accountId, address)?.let {
+            val contactId = resolveContactId(accountId, address.name)
+            Sessions.findByDeviceId(accountId, contactId, address.deviceId)?.let {
                 val sessionRecord = SessionRecord(it[sessionBytes])
-                sessionRecord.hasSenderChain()
-                        && sessionRecord.sessionVersion == CiphertextMessage.CURRENT_VERSION;
+                sessionRecord.hasSenderChain() && sessionRecord.sessionVersion == CiphertextMessage.CURRENT_VERSION
             } ?: false
         }
 
@@ -63,22 +67,22 @@ class SignalcSessionStore(
 
     override fun deleteSession(address: SignalProtocolAddress) {
         lock.acquireForTransaction(db) {
-            Sessions.deleteByAddress(accountId, address)
+            val contactId = resolveContactId(accountId, address.name)
+            Sessions.deleteByDeviceId(accountId, contactId, address.deviceId)
         }
     }
 
 
     override fun deleteAllSessions(name: String) {
         lock.acquireForTransaction(db) {
-            Sessions.deleteWhere {
-                Sessions.accountId eq accountId and (Sessions.contactId eq name)
-            }
+            Sessions.deleteByContactId(accountId, resolveContactId(accountId, name))
         }
     }
 
     override fun archiveSession(address: SignalProtocolAddress) {
         lock.acquireForTransaction(db) {
-            Sessions.findByAddress(accountId, address)?.let {
+            val contactId = resolveContactId(accountId, address.name)
+            Sessions.findByDeviceId(accountId, contactId, address.deviceId)?.let {
                 val session = SessionRecord(it[sessionBytes])
                 session.archiveCurrentState()
                 storeSession(address, session)
@@ -88,7 +92,7 @@ class SignalcSessionStore(
 
     fun archiveAllSessions(address: SignalProtocolAddress) {
         lock.acquireForTransaction(db) {
-            val contactId = address.name
+            val contactId = resolveContactId(accountId, address.name)
             Sessions.findManyByContactId(accountId, contactId).forEach {
                 val session = SessionRecord(it[sessionBytes])
                 session.archiveCurrentState()
@@ -96,6 +100,4 @@ class SignalcSessionStore(
             }
         }
     }
-
-
 }
\ No newline at end of file
diff --git a/signalc/src/main/resources/logback.xml b/signalc/src/main/resources/logback.xml
index fcfd0fbbe9378f78006c9295fbf848cd80101261..54a6b0865425e53c299a34fc37c822e5b50cf29f 100644
--- a/signalc/src/main/resources/logback.xml
+++ b/signalc/src/main/resources/logback.xml
@@ -22,6 +22,10 @@
         <appender-ref ref="stdout" />
     </logger>
 
+    <logger name="info.signalboost.signalc.logging.LibSignalLogger" level="error" additivity="false">
+        <appender-ref ref="stdout" />
+    </logger>
+
     <logger name="io.netty" level="info" additivity="false">
          <appender-ref ref="stdout" />
     </logger>
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt
index a05427a9c071567ba7d0e4d16af2b8bc49e8d6bc..afd3adf7e4f6c7579f5827cd6291f77ec7d017ca 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt
@@ -107,8 +107,6 @@ class AccountManagerTest : FreeSpec({
 
         "#verify" - {
             val code = "1312"
-            val saveSlot = slot<VerifiedAccount>()
-            coEvery { app.accountStore.save(account = capture(saveSlot)) } returns Unit
             every { mockProtocolStore.localRegistrationId } returns 42
 
             "when given correct code" - {
@@ -132,9 +130,15 @@ class AccountManagerTest : FreeSpec({
                 "updates the account store" {
                     accountManager.verify(registeredAccount, code)
                     coVerify {
-                        app.accountStore.save(ofType(VerifiedAccount::class))
+                        app.accountStore.save(verifiedAccount)
+                    }
+                }
+
+                "saves a self-referrential contact" {
+                    accountManager.verify(registeredAccount, code)
+                    coVerify {
+                        app.contactStore.createOwnContact(verifiedAccount)
                     }
-                    saveSlot.captured shouldBe verifiedAccount
                 }
 
                 "returns a verified account" {
@@ -293,7 +297,7 @@ class AccountManagerTest : FreeSpec({
             } returns senderCert.serialized
 
             coEvery {
-                app.profileStore.loadProfileKey(verifiedAccount.identifier, any())
+                app.contactStore.loadProfileKey(verifiedAccount.identifier, any())
             } coAnswers  {
                 if (secondArg<String>() == knownContactId) contactProfileKey
                 else null
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt
index 284eed27ef3d37112ba2152824608bf1477b43ec..639a9190da030bb499e04d9e5f1fca036ad50a75 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt
@@ -38,6 +38,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
 import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope
 import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type.*
 import java.io.IOException
+import java.util.*
 import java.util.concurrent.TimeoutException
 import kotlin.io.path.ExperimentalPathApi
 import kotlin.io.path.Path
@@ -68,7 +69,7 @@ class SignalReceiverTest : FreeSpec({
         // val senderAddress = genSignalServiceAddress()
         val senderAddress = genSignalcAddress()
 
-        val timeout = Duration.milliseconds(40)
+        val timeout = Duration.milliseconds(20)
         val pollInterval = Duration.milliseconds(1)
         val now = nowInMillis()
         val expiryTime = genInt()
@@ -118,8 +119,10 @@ class SignalReceiverTest : FreeSpec({
             every {
                 anyConstructed<SignalServiceCipher>().decrypt(any())
             } returns mockk {
+                every { sender } returns senderAddress.asSignalServiceAddress()
                 every { dataMessage.orNull() } returns mockDataMessage.apply {
                     every { body.orNull() } returnsMany cleartexts
+
                 }
             }
 
@@ -334,16 +337,21 @@ class SignalReceiverTest : FreeSpec({
                                 val identityKey = KeyUtil.genIdentityKeyPair().publicKey
                                 val untrustedIdentityError = ProtocolUntrustedIdentityException(
                                     UntrustedIdentityException(recipientAccount.username, identityKey),
-                                    senderAddress.asSignalServiceAddress().identifier,
+                                    senderAddress.identifier,
                                     genDeviceId()
                                 )
+
                                 every {
                                     anyConstructed<SignalServiceCipher>().decrypt(any())
                                 } throws untrustedIdentityError
 
-                                beforeTest { messageReceiver.subscribe(recipientAccount)!! }
+                                coEvery {
+                                    app.contactStore.getContactAddress(recipientAccount.username, senderAddress.identifier)
+                                } returns senderAddress
+
 
                                 "relays InboundIdentityFailure to socket sender" {
+                                    messageReceiver.subscribe(recipientAccount)!!
                                     eventually(timeout, pollInterval) {
                                         coVerify {
                                             app.socketSender.send(
@@ -419,6 +427,7 @@ class SignalReceiverTest : FreeSpec({
                     every {
                         anyConstructed<SignalServiceCipher>().decrypt(any())
                     } returns mockk {
+                        every { sender } returns senderAddress.asSignalServiceAddress()
                         every { dataMessage.orNull() } returns mockDataMessage.apply {
                             every { profileKey.orNull() } returns fakeProfileKey
                         }
@@ -426,10 +435,10 @@ class SignalReceiverTest : FreeSpec({
 
                     "stores the profile key" {
                         messageReceiver.subscribe(recipientAccount)
-                        eventually(timeout) {
+                        eventually(timeout, pollInterval) {
                             coVerify {
-                                app.profileStore.storeProfileKey(
-                                    recipientAccount.address.identifier,
+                                app.contactStore.storeProfileKey(
+                                    recipientAccount.address.id,
                                     senderAddress.identifier,
                                     fakeProfileKey,
                                 )
@@ -463,6 +472,7 @@ class SignalReceiverTest : FreeSpec({
                     every {
                         anyConstructed<SignalServiceCipher>().decrypt(any())
                     } returns mockk {
+                        every { sender } returns senderAddress.asSignalServiceAddress()
                         every { dataMessage.orNull() } returns mockDataMessage.apply {
                             every { body.orNull() } returns cleartextBody
                             every { attachments.orNull() } returns listOf(
@@ -541,6 +551,47 @@ class SignalReceiverTest : FreeSpec({
                         }
                     }
                 }
+
+                "when the receiving account does has not stored a contact for the sender" - {
+                    coEvery {
+                        app.contactStore.hasContact(recipientAccount.id, envelope.sourceIdentifier)
+                    } returns false
+
+                    "creates a contact for the sender" {
+                        messageReceiver.subscribe(recipientAccount)
+                        eventually(timeout, pollInterval) {
+                            coVerify {
+                                app.contactStore.create(recipientAccount, envelope)
+                            }
+                        }
+                    }
+
+                    "sends the contact a profile key" {
+                        messageReceiver.subscribe(recipientAccount)
+                        eventually(timeout * 4, pollInterval) {
+                            coVerify {
+                                app.signalSender.sendProfileKey(recipientAccount, any())
+                            }
+                        }
+                    }
+                }
+            }
+
+            "when signal sends a RECEIPT" - {
+                val (envelope) = signalSendsEnvelopeOf(RECEIPT_VALUE)
+
+                "stores an missing identifiers contained in the receipt" {
+                    messageReceiver.subscribe(recipientAccount)
+                    eventually(timeout, pollInterval) {
+                        coVerify {
+                            app.contactStore.storeMissingIdentifier(
+                                recipientAccount.id,
+                                envelope.sourceE164.get(),
+                                UUID.fromString(envelope.sourceUuid.get()),
+                            )
+                        }
+                    }
+                }
             }
 
             "when issued for an account that is already subscribed" - {
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt
index 865baa77aec2789ff81fa00d6ef48c0de48b989e..e1f8dec6b863e38d6016c06e3b5c05b3642d1cf0 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt
@@ -14,8 +14,10 @@ import info.signalboost.signalc.testSupport.dataGenerators.FileGen.genJpegFile
 import info.signalboost.signalc.testSupport.dataGenerators.SocketRequestGen.genSendAttachment
 import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalDataMessage
 import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalExpirationUpdate
+import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalReceiptMessage
 import info.signalboost.signalc.util.KeyUtil.genUuidStr
 import info.signalboost.signalc.util.TimeUtil
+import info.signalboost.signalc.util.TimeUtil.nowInMillis
 import io.kotest.assertions.timing.eventually
 import io.kotest.core.spec.style.FreeSpec
 import io.kotest.matchers.should
@@ -33,6 +35,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
 import org.whispersystems.signalservice.api.messages.SendMessageResult
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
+import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage
 import org.whispersystems.signalservice.api.push.SignalServiceAddress
 import java.io.File
 import java.io.InputStream
@@ -60,8 +63,24 @@ class SignalSenderTest : FreeSpec({
 
         val app = Application(config).run(testScope)
         val verifiedAccount = genVerifiedAccount()
+        val contactPhoneNumber = genPhoneNumber()
         val timeout = Duration.milliseconds(5)
 
+        val mockSuccess = mockk<SendMessageResult.Success> {
+            every { isNeedsSync } returns true
+            every { isUnidentified } returns false
+            every { duration } returns 0L
+        }
+
+        fun sendingSucceeds() {
+            every {
+                anyConstructed<SignalServiceMessageSender>().sendMessage(any(), any(), any())
+            } returns mockk {
+                every { address } returns contactPhoneNumber.asSignalServiceAddress()
+                every { success } returns mockSuccess
+            }
+        }
+
         beforeSpec {
             mockkObject(TimeUtil)
             mockkConstructor(SignalServiceMessageSender::class)
@@ -114,14 +133,7 @@ class SignalSenderTest : FreeSpec({
             }
         }
 
-        val mockSuccess = mockk<SendMessageResult.Success> {
-            every { isNeedsSync } returns true
-            every { isUnidentified } returns false
-            every { duration } returns 0L
-        }
-
         "#send" - {
-            val recipientPhone = genPhoneNumber()
             val dataMessageSlot = slot<SignalServiceDataMessage>()
             every {
                 anyConstructed<SignalServiceMessageSender>().sendMessage(
@@ -130,14 +142,14 @@ class SignalSenderTest : FreeSpec({
                     capture(dataMessageSlot),
                 )
             } returns mockk {
-                every { address } returns recipientPhone.asSignalServiceAddress()
+                every { address } returns contactPhoneNumber.asSignalServiceAddress()
                 every { success } returns mockSuccess
             }
 
             val now = TimeUtil.nowInMillis()
             suspend fun sendHello(): SignalcSendResult = app.signalSender.send(
                 sender = verifiedAccount,
-                recipient = recipientPhone.asSignalcAddress(),
+                recipient = contactPhoneNumber.asSignalcAddress(),
                 body = "hello!",
                 expiration = 5000,
                 attachments = emptyList(),
@@ -148,8 +160,8 @@ class SignalSenderTest : FreeSpec({
                 val result = sendHello()
                     verify {
                         anyConstructed<SignalServiceMessageSender>().sendMessage(
-                            SignalServiceAddress(null, recipientPhone),
-                            any(), // TODO: we actually pass something here!
+                            SignalServiceAddress(null, contactPhoneNumber),
+                            any(),
                             signalDataMessage(
                                 body = "hello!",
                                 timestamp = now,
@@ -164,7 +176,7 @@ class SignalSenderTest : FreeSpec({
                 every { TimeUtil.nowInMillis() } returns 1000L
                 app.signalSender.send(
                     verifiedAccount,
-                    recipientPhone.asSignalcAddress(),
+                    contactPhoneNumber.asSignalcAddress(),
                     "hello!",
                     DEFAULT_EXPIRY_TIME,
                     emptyList()
@@ -186,7 +198,7 @@ class SignalSenderTest : FreeSpec({
                 }
                 app.signalSender.send(
                     verifiedAccount,
-                    recipientPhone.asSignalcAddress(),
+                    contactPhoneNumber.asSignalcAddress(),
                     "",
                     DEFAULT_EXPIRY_TIME,
                     emptyList()
@@ -204,7 +216,7 @@ class SignalSenderTest : FreeSpec({
                 "when sealed sender tokens can be derived from a recipient's profile key" - {
                     val mockkUnidentifiedAccessPair = mockk<UnidentifiedAccessPair>()
                     coEvery {
-                        app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone)
+                        app.accountManager.getUnidentifiedAccessPair(verifiedAccount.id, contactPhoneNumber)
                     } returns mockkUnidentifiedAccessPair
 
                     "sends a sealed sender message with access tokens derived from key" {
@@ -222,7 +234,7 @@ class SignalSenderTest : FreeSpec({
                 "when sealed sender tokens cannot be derrived for a recipient (b/c missing profile key)" - {
                     "when unsealed messages are allowed" - {
                         coEvery {
-                            app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone)
+                            app.accountManager.getUnidentifiedAccessPair(verifiedAccount.id, contactPhoneNumber)
                         } returns null
 
                         "sends an unsealed-sender message (with no access tokens)" {
@@ -245,10 +257,10 @@ class SignalSenderTest : FreeSpec({
                         )
                         val app2 = Application(config2).run(testScope)
                         coEvery {
-                            app2.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone)
+                            app2.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, contactPhoneNumber)
                         } returns null
 
-                        val recipientAddress = recipientPhone.asSignalcAddress()
+                        val recipientAddress = contactPhoneNumber.asSignalcAddress()
 
                         afterTest {
                             app2.stop()
@@ -297,7 +309,7 @@ class SignalSenderTest : FreeSpec({
 
                 app.signalSender.send(
                     sender = verifiedAccount,
-                    recipient = recipientPhone.asSignalcAddress(),
+                    recipient = contactPhoneNumber.asSignalcAddress(),
                     body = "hello!",
                     expiration = 5000,
                     attachments = listOf(sendAttachment),
@@ -334,6 +346,46 @@ class SignalSenderTest : FreeSpec({
             }
         }
 
+        "#sendProfileKey" - {
+            sendingSucceeds()
+
+            "sends a profile key to a contact" {
+                app.signalSender.sendProfileKey(verifiedAccount, contactPhoneNumber.asSignalcAddress())
+                verify {
+                    anyConstructed<SignalServiceMessageSender>().sendMessage(
+                        contactPhoneNumber.asSignalServiceAddress(),
+                        any(),
+                        signalDataMessage(
+                            isProfileKeyUpdate = true,
+                            profileKey = verifiedAccount.profileKeyBytes,
+                        )
+                    )
+                }
+            }
+        }
+
+        "#sendReceipt" - {
+            val timestamp = nowInMillis()
+            every {
+                anyConstructed<SignalServiceMessageSender>().sendReceipt(any(), any(), any())
+            } returns Unit
+
+            "sends a READ receipt to a contact" {
+                app.signalSender.sendReceipt(verifiedAccount, contactPhoneNumber.asSignalcAddress(), timestamp)
+                verify {
+                    anyConstructed<SignalServiceMessageSender>().sendReceipt(
+                        contactPhoneNumber.asSignalServiceAddress(),
+                        Optional.absent(),
+                        signalReceiptMessage(
+                            type = SignalServiceReceiptMessage.Type.READ,
+                            timestamps = listOf(timestamp),
+                            id = timestamp,
+                        )
+                    )
+                }
+            }
+        }
+
         "#setExpiration" - {
             val recipientPhone = genPhoneNumber()
             every {
@@ -352,7 +404,7 @@ class SignalSenderTest : FreeSpec({
                 verify {
                     anyConstructed<SignalServiceMessageSender>().sendMessage(
                         SignalServiceAddress(null, recipientPhone),
-                        any(), // TODO: assert that we passed correct access pair here!
+                        Optional.absent(),
                         signalExpirationUpdate(60)
                     )
                 }
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt
index 081009c0bf7f7496f49a219656a92f5e84701284..1a4ac35a5c71896fe5de46ecf1a559abb13f46ea 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt
@@ -314,8 +314,6 @@ class SocketReceiverTest : FreeSpec({
                             client.send(sendRequestJson)
                             eventually(timeout) {
                                 coVerify {
-                                    // TODO(aguestuser|2021-02-04): we dont' actually want this.
-                                    //  we want halting errors to be treated like SendResult statuses below!
                                     app.socketSender.send(
                                         requestHandlingError(request, error)
                                     )
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt
index 4cd4882c894ad2babc8a13e35900f6f319d92e91..5fb319643c24f486eb6380ec68f9d0e9029442a1 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt
@@ -122,7 +122,6 @@ class SocketResponseTest : FreeSpec({
         }
 
         "of Cleartext" - {
-            // TODO: modify to match signald
             val response = genCleartext()
 
             "encodes to JSON" {
@@ -180,14 +179,14 @@ class SocketResponseTest : FreeSpec({
                 response.toJson() shouldBe """
                 |{
                    |"type":"decryption_error",
-                   |"sender":{
-                      |"number":"${response.sender.number}",
-                      |"uuid":"${response.sender.uuid}"
-                   |},
                    |"recipient":{
                       |"number":"${response.recipient.number}",
                       |"uuid":"${response.recipient.uuid}"
                    |},
+                   |"sender":{
+                      |"number":"${response.sender?.number}",
+                      |"uuid":"${response.sender?.uuid}"
+                   |},
                    |"error":{
                       |"cause":"${response.error.javaClass.name}",
                       |"message":"${response.error.message}"
@@ -219,7 +218,7 @@ class SocketResponseTest : FreeSpec({
                       |"number":"${response.data.local_address.number}"
                     |},
                     |"remote_address":{
-                      |"number":"${response.data.remote_address.number}"
+                      |"number":"${response.data.remote_address?.number}"
                     |},
                     |"fingerprint":"${response.data.fingerprint}"
                   |}
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1333f268121d75f8626d57fc05e1996799e092d2
--- /dev/null
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt
@@ -0,0 +1,278 @@
+package info.signalboost.signalc.store
+
+import info.signalboost.signalc.util.KeyUtil.genRandomBytes
+import com.zaxxer.hikari.HikariDataSource
+import info.signalboost.signalc.Application
+import info.signalboost.signalc.Config
+import info.signalboost.signalc.testSupport.coroutines.CoroutineUtil.teardown
+import info.signalboost.signalc.testSupport.dataGenerators.AccountGen.genVerifiedAccount
+import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genPhoneNumber
+import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genUuid
+import info.signalboost.signalc.testSupport.dataGenerators.EnvelopeGen.genEnvelope
+import info.signalboost.signalc.util.KeyUtil.genProfileKeyBytes
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.FreeSpec
+import io.kotest.matchers.ints.shouldBeGreaterThan
+import io.kotest.matchers.longs.shouldBeGreaterThan
+import io.kotest.matchers.shouldBe
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.ObsoleteCoroutinesApi
+import kotlinx.coroutines.test.runBlockingTest
+import org.jetbrains.exposed.exceptions.ExposedSQLException
+import java.lang.IllegalStateException
+import java.util.*
+import kotlin.io.path.ExperimentalPathApi
+import kotlin.properties.Delegates
+import kotlin.time.ExperimentalTime
+
+@ExperimentalPathApi
+@ExperimentalTime
+@ObsoleteCoroutinesApi
+@ExperimentalCoroutinesApi
+class ContactStoreTest: FreeSpec({
+    runBlockingTest {
+        val testScope = this
+        val config = Config.mockAllExcept(ContactStore::class, HikariDataSource::class)
+        val app = Application(config).run(testScope)
+
+        val account = genVerifiedAccount()
+        val accountId = account.id
+        val contactProfileKey = genRandomBytes(32)
+        val contactUpdatedProfileKey = genRandomBytes(32)
+
+        afterSpec {
+            app.stop()
+            testScope.teardown()
+        }
+
+        "Contact store" - {
+            afterTest {
+                app.contactStore.deleteAllFor(accountId)
+            }
+
+            "#create" - {
+                val initCount = app.contactStore.count()
+
+                "given all fields for a contact" - {
+                    val id = app.contactStore.create(accountId, genPhoneNumber(), genUuid(), genProfileKeyBytes())
+
+                    "creates a contact and returns its id" {
+                        id shouldBeGreaterThan 0
+                        app.contactStore.count() shouldBeGreaterThan initCount
+                    }
+                }
+
+                "given a phone number but no other fields" - {
+                    val id = app.contactStore.create(accountId, genPhoneNumber(), null)
+
+                    "creates a contact and returns its id" {
+                        id shouldBeGreaterThan 0
+                        app.contactStore.count() shouldBeGreaterThan initCount
+                    }
+                }
+
+                "given a uuid but no other fields" - {
+                    val id = app.contactStore.create(accountId, null, genUuid())
+
+                    "creates a contact and returns its id" {
+                        id shouldBeGreaterThan 0
+                        app.contactStore.count() shouldBeGreaterThan initCount
+                    }
+                }
+
+                "given no phone number or uuid" - {
+                    "throws an illegal state exception" {
+                        shouldThrow<IllegalStateException> {
+                            app.contactStore.create(genPhoneNumber(), null, null)
+                        }
+                    }
+                }
+
+                "given an invalid phone number" - {
+                    "throws a sql exception" {
+                        shouldThrow<ExposedSQLException> {
+                            app.contactStore.create(genPhoneNumber(), "foo", null)
+                        }
+                    }
+                }
+
+                "given an account and an envelope" - {
+                    val envelope = genEnvelope()
+                    val id = app.contactStore.create(account, envelope)
+
+                    "creates a contact and returns its id" {
+                        id shouldBeGreaterThan 0
+                        app.contactStore.count() shouldBeGreaterThan initCount
+                        app.contactStore.hasContact(accountId, envelope.sourceE164.get()) shouldBe true
+                        app.contactStore.hasContact(accountId, envelope.sourceUuid.get()) shouldBe true
+                    }
+                }
+            }
+
+            "#hasContact" - {
+                val contactPhoneNumber = genPhoneNumber()
+                val contactUuid = genUuid()
+
+                beforeTest {
+                    app.contactStore.create(accountId, contactPhoneNumber, contactUuid)
+                }
+
+                "true for the uuid of an existing contact" {
+                    app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe true
+                }
+
+                "false for the uuid of a non-existent contact" {
+                    app.contactStore.hasContact(accountId, genUuid().toString()) shouldBe false
+                }
+
+                "true for the phone number of an existing contact" {
+                    app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe true
+                }
+
+                "false for the phone number of a non-existent contact" - {
+                    app.contactStore.hasContact(accountId, genPhoneNumber()) shouldBe false
+                }
+
+                "false given a junk string" - {
+                    app.contactStore.hasContact(accountId, "foo") shouldBe false
+                }
+            }
+
+            "#resolveContactId" - {
+                var contactPhoneNumber by Delegates.notNull<String>()
+                var contactUuid by Delegates.notNull<UUID>()
+                var contactId by Delegates.notNull<Int>()
+                var initCount by Delegates.notNull<Long>()
+
+                beforeTest {
+                    contactPhoneNumber = genPhoneNumber()
+                    contactUuid = genUuid()
+                    contactId = app.contactStore.create(accountId, contactPhoneNumber, contactUuid)
+                    initCount = app.contactStore.count()
+                }
+
+                "given the uuid of an existing contact" - {
+                    app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe true
+
+                    "returns the numeric id of that contact" {
+                        app.contactStore.resolveContactId(accountId, contactUuid.toString()) shouldBe contactId
+                        app.contactStore.count() shouldBe initCount
+                    }
+                }
+
+                "given the uuid of a non-existent contact" - {
+                    val newUuid = genUuid()
+                    app.contactStore.hasContact(accountId, newUuid.toString()) shouldBe false
+
+                    "creates a new contact with that uuid and returns its numeric id" {
+                        app.contactStore.resolveContactId(accountId, newUuid.toString()) shouldBeGreaterThan  contactId
+                        app.contactStore.count() shouldBeGreaterThan initCount
+                        app.contactStore.hasContact(accountId, newUuid.toString()) shouldBe true
+                    }
+                }
+
+                "given the phone number of an existing contact" - {
+                    app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe true
+
+                    "returns the numeric id of that contact" {
+                        app.contactStore.resolveContactId(accountId, contactPhoneNumber) shouldBe contactId
+                        app.contactStore.count() shouldBe initCount
+                    }
+                }
+
+                "given the phone number for a non-existent contact" - {
+                    val newPhoneNumber = genPhoneNumber()
+
+                    "creates a new phone number with that uuid and returns its numeric id" {
+                        app.contactStore.resolveContactId(accountId, newPhoneNumber) shouldBeGreaterThan contactId
+                        app.contactStore.count() shouldBeGreaterThan initCount
+                    }
+                }
+
+                "given a junk string for a phone number" - {
+                    "throws SQL error" {
+                        shouldThrow<ExposedSQLException> {
+                            app.contactStore.resolveContactId(accountId, "foo")
+                        }
+                    }
+                }
+            }
+
+            "#storeMissingIdentifier" - {
+                val contactPhoneNumber = genPhoneNumber()
+                val contactUuid = genUuid()
+
+                "given a known phone number and a missing uuid" - {
+                    app.contactStore.create(accountId, contactPhoneNumber, null)
+                    app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe false
+
+                    "stores the uuid" {
+                        app.contactStore.storeMissingIdentifier(accountId, contactPhoneNumber, contactUuid)
+                        app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe true
+                    }
+                }
+
+                "given a known uuid and a missing phone number" - {
+                    app.contactStore.create(accountId, null, contactUuid)
+                    app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe false
+
+                    "stores the phone number" {
+                        app.contactStore.storeMissingIdentifier(accountId, contactPhoneNumber, contactUuid)
+                        app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe true
+                    }
+                }
+
+                "given an unknown uuid and an unknown phone number" - {
+                    "throws IllegalStateException" {
+                        shouldThrow<IllegalStateException> {
+                            app.contactStore.storeMissingIdentifier(accountId, contactPhoneNumber, contactUuid)
+                        }
+                    }
+                }
+            }
+
+            "#loadProfileKey" - {
+                val contactId = genPhoneNumber()
+
+                beforeTest {
+                    app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey)
+                }
+
+                "returns profile key for a contact if it exists" {
+                    app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey
+                }
+
+                "returns null if no profile key exists for contact" {
+                    app.contactStore.loadProfileKey(accountId, genPhoneNumber()) shouldBe null
+                }
+            }
+
+            "#storeProfileKey" - {
+                val contactId = genPhoneNumber()
+
+                "stores a new profile key for a new contact" {
+                    val startingCount = app.contactStore.count()
+                    app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey)
+                    app.contactStore.count() shouldBe startingCount + 1
+                    app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey
+                }
+
+                "overwrites the old profile key for an existing contact" {
+                    val startingCount = app.contactStore.count()
+                    app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey)
+                    app.contactStore.storeProfileKey(accountId, contactId, contactUpdatedProfileKey)
+                    app.contactStore.count() shouldBe startingCount + 1
+                    app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactUpdatedProfileKey
+                }
+
+                "safely stores the same profile key twice" {
+                    val startingCount = app.contactStore.count()
+                    app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey)
+                    app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey)
+                    app.contactStore.count() shouldBe startingCount + 1
+                    app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey
+                }
+            }
+        }
+    }
+})
\ No newline at end of file
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProfileStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ProfileStoreTest.kt
deleted file mode 100644
index 8abba4eebecfdd5c8a8998002d01e762a85eeba3..0000000000000000000000000000000000000000
--- a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProfileStoreTest.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package info.signalboost.signalc.store
-
-import info.signalboost.signalc.util.KeyUtil.genRandomBytes
-import org.signal.zkgroup.profiles.ProfileKey
-import com.zaxxer.hikari.HikariDataSource
-import info.signalboost.signalc.Application
-import info.signalboost.signalc.Config
-import info.signalboost.signalc.testSupport.coroutines.CoroutineUtil.teardown
-import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genPhoneNumber
-import io.kotest.core.spec.style.FreeSpec
-import io.kotest.matchers.shouldBe
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.ObsoleteCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
-import kotlin.io.path.ExperimentalPathApi
-import kotlin.time.ExperimentalTime
-
-@ExperimentalPathApi
-@ExperimentalTime
-@ObsoleteCoroutinesApi
-@ExperimentalCoroutinesApi
-class ProfileStoreTest: FreeSpec({
-    runBlockingTest {
-        val testScope = this
-        val config = Config.mockAllExcept(ProfileStore::class, HikariDataSource::class)
-        val app = Application(config).run(testScope)
-
-        val accountId = genPhoneNumber()
-        val contactId = genPhoneNumber()
-        val contactProfileKey = genRandomBytes(32)
-        val contactUpdatedProfileKey = genRandomBytes(32)
-
-        afterSpec {
-            app.stop()
-            testScope.teardown()
-        }
-
-        "Profile store" - {
-            afterTest {
-                app.profileStore.deleteAllFor(accountId)
-            }
-
-            "#loadProfileKey" - {
-                beforeTest {
-                    app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey)
-                }
-
-                "returns profile key for a contact if it exists" {
-                    app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey
-                }
-
-                "returns null if no profile key exists for contact" {
-                    app.profileStore.loadProfileKey(accountId, genPhoneNumber()) shouldBe null
-                }
-            }
-
-            "#storeProfileKey" - {
-                "stores a new profile key for a new contact" {
-                    val startingCount = app.profileStore.count()
-                    app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey)
-                    app.profileStore.count() shouldBe startingCount + 1
-                    app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey
-                }
-
-                "overwrites the old profile key for an existing contact" {
-                    val startingCount = app.profileStore.count()
-                    app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey)
-                    app.profileStore.storeProfileKey(accountId, contactId, contactUpdatedProfileKey)
-                    app.profileStore.count() shouldBe startingCount + 1
-                    app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactUpdatedProfileKey
-                }
-
-                "safely stores the same profile key twice" {
-                    val startingCount = app.profileStore.count()
-                    app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey)
-                    app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey)
-                    app.profileStore.count() shouldBe startingCount + 1
-                    app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey
-                }
-            }
-        }
-    }
-})
\ No newline at end of file
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt
index f8a1fc2a18e5c547d390b29d11c237f11d1f7dcd..fb93d08e0aca5c488c48dd1221aa7c24050f1d4f 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt
@@ -32,13 +32,11 @@ import kotlin.time.ExperimentalTime
 class ProtocolStoreTest: FreeSpec({
     runBlockingTest {
         val testScope = this
-        val accountId = genPhoneNumber()
-        val config = Config.mockAllExcept(ProtocolStore::class, HikariDataSource::class)
+        val config = Config.mockAllExcept(HikariDataSource::class, ProtocolStore::class, ContactStore::class)
         val app = Application(config).run(testScope)
-        val store = app.protocolStore.of(NewAccount(accountId))
 
-        val senderOneAddress = SignalProtocolAddress(accountId, 42)
-        val senderTwoAddress = SignalProtocolAddress(genPhoneNumber(), 15)
+        val accountId = genPhoneNumber()
+        val accountAddress = SignalProtocolAddress(accountId, 42)
         // NOTE: An address is a combination of a username (uuid or e164-format phone number) and a device id.
         // This is how Signal represents that a user may have many devices and each device has its own session.
         val contact = object {
@@ -53,22 +51,22 @@ class ProtocolStoreTest: FreeSpec({
             )
         }
         val contactAddress = contact.addresses[0]
+        val store = app.protocolStore.of(NewAccount(accountId))
 
+        beforeSpec {
+            app.contactStore.create(accountId, contact.phoneNumber, null)
+        }
         afterSpec {
             app.stop()
             testScope.teardown()
         }
 
-        // TODO: all of the Identity tests (confusingly) assert on addresses for "sender"s
-        //  (but the only identities we care about are for recipients)
-
         "Identities store" - {
             val identityKey = KeyUtil.genIdentityKeyPair().publicKey
             val rotatedIdentityKey = KeyUtil.genIdentityKeyPair().publicKey
 
             afterTest {
-                store.removeIdentity(senderOneAddress)
-                store.removeIdentity(senderTwoAddress)
+                store.removeIdentity(accountAddress)
                 store.removeIdentity(contact.addresses[0])
                 store.removeIdentity(contact.addresses[1])
                 store.removeOwnIdentity()
@@ -107,12 +105,12 @@ class ProtocolStoreTest: FreeSpec({
             }
 
             "trusts the first key it sees for an address" {
-                store.isTrustedIdentity(senderOneAddress, identityKey, Direction.RECEIVING) shouldBe true
+                store.isTrustedIdentity(accountAddress, identityKey, Direction.RECEIVING) shouldBe true
             }
 
             "trusts a key it has stored for an address" {
-                store.saveIdentity(senderOneAddress, identityKey)
-                store.isTrustedIdentity(senderOneAddress, identityKey, Direction.RECEIVING) shouldBe true
+                store.saveIdentity(accountAddress, identityKey)
+                store.isTrustedIdentity(accountAddress, identityKey, Direction.RECEIVING) shouldBe true
             }
 
             "trusts the same identity key for multiple devices" {
@@ -342,7 +340,7 @@ class ProtocolStoreTest: FreeSpec({
 
             afterTest {
                 store.removeOwnIdentity()
-                store.removeIdentity(senderOneAddress)
+                store.removeIdentity(accountAddress)
                 store.removePreKey(ids[0])
                 store.removeSignedPreKey(ids[0])
                 store.deleteAllSessions(contact.phoneNumber)
@@ -357,12 +355,12 @@ class ProtocolStoreTest: FreeSpec({
             "support separate and distinct identities" {
                 store.identityKeyPair.serialize() shouldNotBe otherStore.identityKeyPair.serialize()
 
-                store.saveIdentity(senderOneAddress, KeyUtil.genIdentityKeyPair().publicKey)
+                store.saveIdentity(accountAddress, KeyUtil.genIdentityKeyPair().publicKey)
                 otherStore.saveIdentity(otherAddress, KeyUtil.genIdentityKeyPair().publicKey)
 
-                store.getIdentity(senderOneAddress) shouldNotBe otherStore.getIdentity(otherAddress)
+                store.getIdentity(accountAddress) shouldNotBe otherStore.getIdentity(otherAddress)
                 store.getIdentity(otherAddress) shouldBe null
-                otherStore.getIdentity(senderOneAddress) shouldBe null
+                otherStore.getIdentity(accountAddress) shouldBe null
             }
 
             "support separate and distinct prekeys" {
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt
index ca0393078f2ac8c6ddd7766fb60f14bf9848193d..6fc0f581b898fcd0a8aae32cd2b0f1f33fe23ace 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt
@@ -1,8 +1,6 @@
 package info.signalboost.signalc.testSupport.db
 
-import info.signalboost.signalc.db.Identities
-import info.signalboost.signalc.db.Profiles
-import info.signalboost.signalc.db.SenderKeys
+import info.signalboost.signalc.db.Contacts
 import org.jetbrains.exposed.sql.*
 import org.jetbrains.exposed.sql.transactions.transaction
 
@@ -31,7 +29,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
 
 fun main() {
     // change this assignment to
-    val table = Profiles
+    val table = Contacts
     val db = Database.connect(
         driver = "org.postgresql.Driver",
         url = "jdbc:postgresql://localhost:5432/signalc_scratch",
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt
index 2431b04c2f51c46b2d92174ae117bf2bb94ddab8..fcdf41dd611c0f9353ef0c673c7cd2bc68bede81 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt
@@ -4,6 +4,7 @@ import io.mockk.MockKMatcherScope
 import org.whispersystems.libsignal.util.guava.Optional
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
+import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage
 
 object SignalMessageMatchers {
 
@@ -12,14 +13,30 @@ object SignalMessageMatchers {
         timestamp: Long? = null,
         expiresInSeconds: Int? = null,
         attachments: Optional<List<SignalServiceAttachment>>? = null,
+        isProfileKeyUpdate: Boolean? = null,
+        profileKey: ByteArray? = null,
     ): SignalServiceDataMessage = match {
         // check for equality of each provided param. if param not provided, don't check it!
         body?.let{ _ -> it.body.or("") == body } ?: true &&
-            timestamp?.let { _ -> it.timestamp == timestamp } ?: true &&
-            expiresInSeconds?.let { _ -> it.expiresInSeconds == expiresInSeconds } ?: true &&
-            attachments?.let { _ -> it.attachments == attachments } ?: true
+                timestamp?.let { _ -> it.timestamp == timestamp } ?: true &&
+                expiresInSeconds?.let { _ -> it.expiresInSeconds == expiresInSeconds } ?: true &&
+                attachments?.let { _ -> it.attachments == attachments } ?: true &&
+                isProfileKeyUpdate?.let { _ -> it.isProfileKeyUpdate == isProfileKeyUpdate} ?: true &&
+                profileKey?.let { _ -> it.profileKey.or(ByteArray(0)).contentEquals(profileKey) } ?: true
     }
 
+    fun MockKMatcherScope.signalReceiptMessage(
+        type: SignalServiceReceiptMessage.Type? = null,
+        timestamps: List<Long>? = null,
+        id: Long? = null,
+    ): SignalServiceReceiptMessage = match {
+        // check for equality of each provided param. if param not provided, don't check it!
+        type?.let{ _ -> it.type == type } ?: true &&
+                timestamps?.let { _ -> it.timestamps == timestamps } ?: true &&
+                id?.let { _ -> it.`when` == id } ?: true
+    }
+
+
     fun MockKMatcherScope.signalExpirationUpdate(
         expiresInSeconds: Int,
     ): SignalServiceDataMessage = match {
diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt
index b0d277fdde334dc41188f0eda189c40ac2143039..a8bcab06579f0d9dc1b589472264fa607433f554 100644
--- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt
+++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt
@@ -48,11 +48,11 @@ object SocketResponseMatchers {
 
     fun MockKMatcherScope.inboundIdentityFailure(
         sender: SignalcAddress,
-        recipient: SignalcAddress,
+        recipient: SignalcAddress?,
         fingerprint: String?
     ): SocketResponse.InboundIdentityFailure = match {
-        it.data.local_address.number == recipient.number &&
-                it.data.remote_address.number == sender.number &&
+        it.data.local_address.number == recipient?.number &&
+                it.data.remote_address?.number == sender.number &&
                 it.data.fingerprint == fingerprint
     }