fix: contact name wiped on refresh, migrate to paymentHash instead of checkingId

This commit is contained in:
2026-06-13 22:52:12 +02:00
parent c817768e8a
commit effe8f4b04
10 changed files with 183 additions and 77 deletions
@@ -4,6 +4,7 @@ import android.app.Application
import com.bitcointxoko.gudariwallet.data.db.* import com.bitcointxoko.gudariwallet.data.db.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber
class ContactRepository(app: Application) { class ContactRepository(app: Application) {
private val dao = AppDatabase.getInstance(app).contactDao() private val dao = AppDatabase.getInstance(app).contactDao()
@@ -125,15 +126,17 @@ class ContactRepository(app: Application) {
// --- Contact-Tx link --- // --- Contact-Tx link ---
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> = fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(checkingId) dao.observeContactsForPayment(paymentHash)
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) { suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role)) Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
} }
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) { suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
dao.unlinkPaymentFromContact(checkingId, contactId) Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
dao.unlinkPaymentFromContact(paymentHash, contactId)
} }
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> = fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
@@ -141,11 +144,20 @@ class ContactRepository(app: Application) {
fun observeAllPaymentContactNames(): Flow<Map<String, String>> = fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames() dao.observePaymentContactNames()
.map { list -> list.associate { it.checkingId to it.contactName } } .map { list ->
list.associate { it.paymentHash to it.contactName }
.also { map ->
Timber.d(
"CONTACT [MAP EMIT ] ${map.size} entries: ${
map.entries.take(5).joinToString { "${it.key.take(8)}${it.value}" }
}"
)
}
}
suspend fun findCheckingIdsByContactName(query: String): List<String> = suspend fun findPaymentHashesByContactName(query: String): List<String> =
dao.findCheckingIdsByContactName(query) dao.findPaymentHashesByContactName(query)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> = suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
dao.findCheckingIdsByContactIds(contactIds) dao.findPaymentHashesByContactIds(contactIds)
} }
@@ -58,13 +58,33 @@ class PaymentCacheRepository(context: Context) {
if (payments.isEmpty()) return if (payments.isEmpty()) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
runCatching { runCatching {
dao.insertAll(payments.map { it.toEntity(now) }) payments.forEach { payment ->
val e = payment.toEntity(now)
Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}")
dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE)
dao.updateSafe( // always patches server-owned fields in-place
checkingId = e.checkingId,
paymentHash = e.paymentHash,
amountMsat = e.amountMsat,
feeMsat = e.feeMsat,
memo = e.memo,
time = e.time,
createdAt = e.createdAt,
status = e.status,
bolt11 = e.bolt11,
pending = e.pending,
preimage = e.preimage,
extra = e.extra,
savedAt = e.savedAt
)
}
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})") Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
}.onFailure { }.onFailure {
Timber.w("PAYMENTS [MERGE ERR] ${it.message}") Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
} }
} }
/** Persist the first page of payments to the database. */ /** Persist the first page of payments to the database. */
suspend fun savePayments(payments: List<PaymentRecord>) { suspend fun savePayments(payments: List<PaymentRecord>) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -130,8 +150,24 @@ class PaymentCacheRepository(context: Context) {
/** Upsert PaymentRecord to database. */ /** Upsert PaymentRecord to database. */
suspend fun upsertPayment(payment: PaymentRecord) { suspend fun upsertPayment(payment: PaymentRecord) {
val entity = payment.toEntity(savedAt = System.currentTimeMillis()) val e = payment.toEntity(savedAt = System.currentTimeMillis())
dao.insert(entity) Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}")
dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE
dao.updateSafe( // always patches server-owned fields in-place
checkingId = e.checkingId,
paymentHash = e.paymentHash,
amountMsat = e.amountMsat,
feeMsat = e.feeMsat,
memo = e.memo,
time = e.time,
createdAt = e.createdAt,
status = e.status,
bolt11 = e.bolt11,
pending = e.pending,
preimage = e.preimage,
extra = e.extra,
savedAt = e.savedAt
)
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}") Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
} }
@@ -166,11 +202,11 @@ class PaymentCacheRepository(context: Context) {
* contact ID filter. Amount/date/status are applied in SQL; * contact ID filter. Amount/date/status are applied in SQL;
* direction and type are handled in-memory afterwards (same as queryAll). * direction and type are handled in-memory afterwards (same as queryAll).
*/ */
suspend fun queryByCheckingIds( suspend fun queryByPaymentHashes(
checkingIds : List<String>, paymentHashes : List<String>,
filter : PaymentFilter filter : PaymentFilter
): List<PaymentRecord> { ): List<PaymentRecord> {
if (checkingIds.isEmpty()) return emptyList() if (paymentHashes.isEmpty()) return emptyList()
val statusPattern: String? = if (filter.statuses.isEmpty()) null else { val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
filter.statuses.flatMap { s -> filter.statuses.flatMap { s ->
when (s) { when (s) {
@@ -180,8 +216,8 @@ class PaymentCacheRepository(context: Context) {
} }
}.joinToString(",") }.joinToString(",")
} }
return dao.getByCheckingIdsFiltered( return dao.getByPaymentHashesFiltered(
checkingIds = checkingIds, paymentHashes = paymentHashes,
minAmountMsat = filter.minAmountSat?.let { it * 1000L }, minAmountMsat = filter.minAmountSat?.let { it * 1000L },
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L }, maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
minCreatedAt = filter.minCreatedAt, minCreatedAt = filter.minCreatedAt,
@@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PaymentAddressEntity::class, PaymentAddressEntity::class,
TxContactCrossRef::class, TxContactCrossRef::class,
], ],
version = 9, version = 10,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -195,6 +195,30 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_9_10 = object : Migration(9, 9 + 1) {
override fun migrate(database: SupportSQLiteDatabase) {
// 1. Create replacement table — same schema minus the payment_records FK
database.execSQL("""
CREATE TABLE IF NOT EXISTS tx_contact_links_new (
checkingId TEXT NOT NULL,
contactId TEXT NOT NULL,
role TEXT,
PRIMARY KEY (checkingId, contactId),
FOREIGN KEY (contactId) REFERENCES contacts(id)
ON DELETE CASCADE
)
""")
// 2. Copy existing rows
database.execSQL("""
INSERT INTO tx_contact_links_new (checkingId, contactId, role)
SELECT checkingId, contactId, role FROM tx_contact_links
""")
// 3. Swap tables
database.execSQL("DROP TABLE tx_contact_links")
database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links")
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -210,7 +234,8 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_5_6, MIGRATION_5_6,
MIGRATION_6_7, MIGRATION_6_7,
MIGRATION_7_8, MIGRATION_7_8,
MIGRATION_8_9 MIGRATION_8_9,
MIGRATION_9_10
) )
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
@@ -76,51 +76,51 @@ interface ContactDao {
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef) suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
// Delete a link // Delete a link
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId") @Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
// Delete all contact links for a payment // Delete all contact links for a payment
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId") @Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
suspend fun clearContactsForPayment(checkingId: String) suspend fun clearContactsForPayment(paymentHash: String)
// Get all contacts linked to a payment // Get all contacts linked to a payment
@Query(""" @Query("""
SELECT c.* FROM contacts c SELECT c.* FROM contacts c
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
WHERE tcl.checkingId = :checkingId WHERE tcl.paymentHash = :paymentHash
""") """)
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
// Get all payments linked to a contact // Get all payments linked to a contact
@Query(""" @Query("""
SELECT checkingId FROM tx_contact_links SELECT paymentHash FROM tx_contact_links
WHERE contactId = :contactId WHERE contactId = :contactId
ORDER BY createdAt DESC ORDER BY createdAt DESC
""") """)
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
// Returns all (checkingId, displayName) pairs that have a linked contact, // Returns all (paymentHash, displayName) pairs that have a linked contact,
// so HistoryViewModel can build its lookup map in a single reactive query. // so HistoryViewModel can build its lookup map in a single reactive query.
@Query(""" @Query("""
SELECT tcl.checkingId AS checkingId, SELECT tcl.paymentHash AS paymentHash,
COALESCE(c.localAlias, c.displayName, c.name) AS contactName COALESCE(c.localAlias, c.displayName, c.name) AS contactName
FROM tx_contact_links tcl FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId INNER JOIN contacts c ON c.id = tcl.contactId
""") """)
fun observePaymentContactNames(): Flow<List<PaymentContactName>> fun observePaymentContactNames(): Flow<List<PaymentContactName>>
// For text search matching contact names — returns checkingIds of matched payments // For text search matching contact names — returns paymentHashes of matched payments
@Query(""" @Query("""
SELECT tcl.checkingId FROM tx_contact_links tcl SELECT tcl.paymentHash FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId INNER JOIN contacts c ON c.id = tcl.contactId
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%' WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
""") """)
suspend fun findCheckingIdsByContactName(query: String): List<String> suspend fun findPaymentHashesByContactName(query: String): List<String>
// For filter-by-contact — returns checkingIds for specific contact IDs // For filter-by-contact — returns paymentHashs for specific contact IDs
@Query(""" @Query("""
SELECT DISTINCT checkingId FROM tx_contact_links SELECT DISTINCT paymentHash FROM tx_contact_links
WHERE contactId IN (:contactIds) WHERE contactId IN (:contactIds)
""") """)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
} }
@@ -1,6 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
data class PaymentContactName( data class PaymentContactName(
val checkingId : String, val paymentHash : String,
val contactName : String val contactName : String
) )
@@ -13,6 +13,45 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(payment: PaymentRecordEntity) suspend fun insert(payment: PaymentRecordEntity)
// Safe upsert used by mergePayments only.
// IGNORE skips the row on conflict (no DELETE), then updateSafe patches
// the server-owned fields in-place — the parent row is never deleted,
// so the TxContactCrossRef CASCADE is never triggered.
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertIgnore(payment: PaymentRecordEntity)
@Query("""
UPDATE payment_records SET
paymentHash = :paymentHash,
amountMsat = :amountMsat,
feeMsat = :feeMsat,
memo = :memo,
time = :time,
createdAt = :createdAt,
status = :status,
bolt11 = :bolt11,
pending = :pending,
preimage = :preimage,
extra = :extra,
savedAt = :savedAt
WHERE checkingId = :checkingId
""")
suspend fun updateSafe(
checkingId: String,
paymentHash: String,
amountMsat: Long,
feeMsat: Long,
memo: String?,
time: String,
createdAt: Long?,
status: String,
bolt11: String?,
pending: Boolean,
preimage: String?,
extra: String?,
savedAt: Long
)
/** Returns all rows ordered by time descending. */ /** Returns all rows ordered by time descending. */
@Query("SELECT * FROM payment_records ORDER BY time DESC") @Query("SELECT * FROM payment_records ORDER BY time DESC")
suspend fun getAll(): List<PaymentRecordEntity> suspend fun getAll(): List<PaymentRecordEntity>
@@ -38,7 +77,7 @@ interface PaymentDao {
@Query(""" @Query("""
SELECT * FROM payment_records SELECT * FROM payment_records
WHERE checkingId IN (:checkingIds) WHERE paymentHash IN (:paymentHashes)
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat) AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat) AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt) AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
@@ -47,8 +86,8 @@ interface PaymentDao {
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%') ',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
ORDER BY createdAt DESC, time DESC ORDER BY createdAt DESC, time DESC
""") """)
suspend fun getByCheckingIdsFiltered( suspend fun getByPaymentHashesFiltered(
checkingIds : List<String>, paymentHashes : List<String>,
minAmountMsat : Long? = null, minAmountMsat : Long? = null,
maxAmountMsat : Long? = null, maxAmountMsat : Long? = null,
minCreatedAt : Long? = null, minCreatedAt : Long? = null,
@@ -6,28 +6,10 @@ import androidx.room.Index
@Entity( @Entity(
tableName = "tx_contact_links", tableName = "tx_contact_links",
primaryKeys = ["checkingId", "contactId"], primaryKeys = ["paymentHash", "contactId"]
foreignKeys = [
ForeignKey(
entity = PaymentRecordEntity::class,
parentColumns = ["checkingId"],
childColumns = ["checkingId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = ContactEntity::class,
parentColumns = ["id"],
childColumns = ["contactId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("checkingId"),
Index("contactId")
]
) )
data class TxContactCrossRef( data class TxContactCrossRef(
val checkingId : String, val paymentHash : String,
val contactId : String, val contactId : String,
val role : String? = null, val role : String? = null,
val createdAt : Long = System.currentTimeMillis() val createdAt : Long = System.currentTimeMillis()
@@ -235,7 +235,7 @@ fun HistoryScreen(
payment = payment, payment = payment,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
contactName = contactNames[payment.checkingId], contactName = contactNames[payment.paymentHash],
onClick = { onPaymentClick(payment) } onClick = { onPaymentClick(payment) }
) )
HorizontalDivider( HorizontalDivider(
@@ -133,13 +133,22 @@ class HistoryViewModel(
} }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults -> val filteredState = combine(
syncManager.state,
_filter, _roomResults,
contactRepo.observeAllPaymentContactNames()
) { s, f, roomResults, contactNames ->
Timber.d("CONTACT [COMBINE ] contactNames has ${contactNames.size} entries")
if (s !is HistoryState.Success) return@combine s if (s !is HistoryState.Success) return@combine s
if (roomResults == null) { val payments = if (roomResults == null) {
s.copy(payments = s.payments.applyInMemoryFilters(f)) s.payments.applyInMemoryFilters(f)
} else { } else {
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false) roomResults.applyInMemoryFilters(f)
} }
s.copy(
payments = payments,
canLoadMore = roomResults == null && s.canLoadMore
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
// ── In-memory filter logic ──────────────────────────────────────────────── // ── In-memory filter logic ────────────────────────────────────────────────
@@ -164,7 +173,7 @@ class HistoryViewModel(
contactRepo.observeAllPaymentContactNames() contactRepo.observeAllPaymentContactNames()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.Eagerly,
initialValue = emptyMap() initialValue = emptyMap()
) )
// Maps contactId → display name (for filter chips) // Maps contactId → display name (for filter chips)
@@ -203,6 +212,7 @@ class HistoryViewModel(
) )
fun assignContact(paymentHash: String, contactId: String) { fun assignContact(paymentHash: String, contactId: String) {
Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
viewModelScope.launch { viewModelScope.launch {
contactRepo.linkPaymentToContact(paymentHash, contactId) contactRepo.linkPaymentToContact(paymentHash, contactId)
} }
@@ -220,6 +230,7 @@ class HistoryViewModel(
addressType : PaymentAddressType?, addressType : PaymentAddressType?,
address : String? address : String?
) { ) {
Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
viewModelScope.launch { viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS } val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
@@ -228,6 +239,7 @@ class HistoryViewModel(
lnAddress = lnAddress, lnAddress = lnAddress,
lnUrl = lnUrl lnUrl = lnUrl
) )
Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash")
contactRepo.linkPaymentToContact(paymentHash, contact.id) contactRepo.linkPaymentToContact(paymentHash, contact.id)
} }
} }
@@ -265,11 +277,11 @@ class HistoryViewModel(
// ── Supplemental contact search ─────────────────────── // ── Supplemental contact search ───────────────────────
// 1. Text query may match contact names not in payment_records // 1. Text query may match contact names not in payment_records
if (f.searchQuery.isNotBlank()) { if (f.searchQuery.isNotBlank()) {
val contactCheckingIds = contactRepo val contactPaymentHashes = contactRepo
.findCheckingIdsByContactName(f.searchQuery) .findPaymentHashesByContactName(f.searchQuery)
if (contactCheckingIds.isNotEmpty()) { if (contactPaymentHashes.isNotEmpty()) {
val contactPayments = paymentCache val contactPayments = paymentCache
.queryByCheckingIds(contactCheckingIds, f) .queryByPaymentHashes(contactPaymentHashes, f)
// Merge without duplicates // Merge without duplicates
val existingIds = baseResults.map { it.checkingId }.toSet() val existingIds = baseResults.map { it.checkingId }.toSet()
baseResults += contactPayments baseResults += contactPayments
@@ -279,12 +291,12 @@ class HistoryViewModel(
// 2. Explicit contact ID filter // 2. Explicit contact ID filter
if (f.contactIds.isNotEmpty()) { if (f.contactIds.isNotEmpty()) {
val contactCheckingIds = contactRepo val contactPaymentHashes = contactRepo
.findCheckingIdsByContactIds(f.contactIds.toList()) .findPaymentHashesByContactIds(f.contactIds.toList())
if (contactCheckingIds.isNotEmpty()) { if (contactPaymentHashes.isNotEmpty()) {
// Fetch ALL payments for these contacts, WITH the full filter applied // Fetch ALL payments for these contacts, WITH the full filter applied
val contactFiltered = paymentCache.queryByCheckingIds( val contactFiltered = paymentCache.queryByPaymentHashes(
checkingIds = contactCheckingIds, paymentHashes = contactPaymentHashes,
filter = f filter = f
) )
_roomResults.value = contactFiltered _roomResults.value = contactFiltered
@@ -400,7 +400,7 @@ class SendViewModel(
// ── 3. Link payment → contact (FK now guaranteed to exist) ─────── // ── 3. Link payment → contact (FK now guaranteed to exist) ───────
runCatching { runCatching {
contactRepo.linkPaymentToContact( contactRepo.linkPaymentToContact(
checkingId = paymentHash, paymentHash = paymentHash,
contactId = contactId, contactId = contactId,
role = "recipient", role = "recipient",
) )