Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -830,8 +830,24 @@ interface DashPayBackfillGate {
* accounts. Any positive count means the last sweep reconciled the rewind
* before those accounts existed, so another sweep is owed; see
* [decideDashPayBackfill]. Zero is ignored. Never throws.
*
* Accepting the registrations also DURABLY invalidates any persisted
* coverage — the same four-key removal the address-window heal performs
* ([SdkWalletBinder.maybeWidenAddressWindows]). The in-memory re-provision
* signal dies with the process, and a recorded coverage describes a scan
* that ran WITHOUT the just-registered accounts' addresses in the watched
* script set: surviving a crash between the drain and the follow-up
* sweep, it would suppress the backfill those accounts are owed on every
* later launch. The clear lands BEFORE this call returns — i.e. before
* any follow-up sweep the caller runs on the verdict — so a process death
* in between self-heals on the next launch.
*
* @return true when the registrations were accepted as NEW — a follow-up
* sweep is owed for them and the caller may run one now; false for
* zero/negative counts and for re-builds muted by the per-process loop
* guard (see [DashPayBackfillGateImpl.buildsNotedThisProcess]).
*/
fun noteAccountBuildsRegistered(built: Int)
suspend fun noteAccountBuildsRegistered(built: Int): Boolean

/**
* The persisted bookkeeping, for consumers that must not treat a
Expand All @@ -855,7 +871,9 @@ interface DashPayBackfillGate {
* anything IS accounted for, so still being unaccounted-for after several
* minutes of polling is itself the evidence that no rewind is coming.
* Re-checks the height and fingerprint itself and refuses if a rewind did
* land. Returns whether coverage was recorded. Never throws.
* land — or if account builds were registered since the last sweep (a
* sweep is then still OWED, so quiet is not evidence of anything).
* Returns whether coverage was recorded. Never throws.
*/
suspend fun concludeNoRewindObserved(
walletIdHex: String,
Expand Down Expand Up @@ -885,7 +903,8 @@ interface DashPayBackfillGate {
// Nothing is ever armed, so there is never anything to watch for.
override suspend fun isRewindAccountedFor() = true

override fun noteAccountBuildsRegistered(built: Int) = Unit
// Nothing is recorded, so nothing is owed a follow-up sweep.
override suspend fun noteAccountBuildsRegistered(built: Int) = false

// Nothing is ever recorded, so nothing is ever owed.
override suspend fun readBackfillStatus() = DashPayBackfillStatus.SETTLED
Expand Down Expand Up @@ -960,8 +979,8 @@ class DashPayBackfillGateImpl @Inject constructor(
*/
private val buildsNotedThisProcess = java.util.concurrent.atomic.AtomicInteger(0)

override fun noteAccountBuildsRegistered(built: Int) {
if (built <= 0) return
override suspend fun noteAccountBuildsRegistered(built: Int): Boolean {
if (built <= 0) return false
val cap = lastObservation.sdkContactCount
if (cap > 0 && buildsNotedThisProcess.get() >= cap) {
log.info(
Expand All @@ -970,7 +989,7 @@ class DashPayBackfillGateImpl @Inject constructor(
"already-swept accounts, NOT raising the re-provision signal (loop guard)",
built, buildsNotedThisProcess.get(), cap
)
return
return false
}
buildsNotedThisProcess.addAndGet(built)
if (accountsRegisteredSincePass.compareAndSet(false, true)) {
Expand All @@ -982,6 +1001,34 @@ class DashPayBackfillGateImpl @Inject constructor(
built
)
}
// The DURABLE half (see the interface KDoc): the flag above dies with
// the process, so a persisted coverage record — written over a scan
// that never watched these accounts' addresses — must be invalidated
// in the store NOW, before the caller's follow-up sweep gets a chance
// to run. A crash between this drain and that sweep then self-heals:
// the next launch finds no coverage and provisions again. Same
// four-key removal as the address-window heal's invalidation.
try {
if (readCoverage() != null) {
clearCoverage()
log.info(
"DashPay coreHeight backfill coverage invalidated — receival account(s) " +
"registered after it was recorded; the next gate pass rewinds with " +
"the enlarged script set"
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// The in-memory signal still forces the next in-process
// consultation; only crash-durability is degraded. Never throw —
// the drain path must survive a store hiccup.
log.warn(
"failed to durably invalidate the DashPay backfill coverage; the in-memory " +
"registration signal still forces a re-sweep this process", e
)
}
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -1141,6 +1188,21 @@ class DashPayBackfillGateImpl @Inject constructor(
armed == null -> false
// Something already accounted for the pass; leave it alone.
readInProgress() != null || readCoverage() != null -> false
// A receival account registered since the last sweep is POSITIVE
// evidence a sweep is still owed — "no rewind observed" cannot
// mean "none was needed" while the sweep that would fire it has
// not run. Recording coverage here would suppress exactly the
// backfill that account needs (forever, if a crash then loses
// the in-memory signal before the owed sweep runs). Refusing
// leaves the armed marker, so the next consultation provisions.
accountsRegisteredSincePass.get() -> {
log.info(
"DashPay coreHeight backfill: NOT concluding no-rewind — account build(s) " +
"were registered since the last sweep, so a sweep (and possibly its " +
"rewind) is still owed; leaving the armed marker in place"
)
false
}
else -> {
val signals = sdkService.readDashPayBackfillSignals(walletIdHex, ownerIdentityId)
val height = signals.syncedHeight
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,35 @@ data class DashPayContactDrainReport(
val built: Int get() = (queuedBefore - queuedAfter).coerceAtLeast(0)
}

/**
* How completely the bound SDK wallet's DIP-15 receival accounts cover its
* established (RECEIVED-request) DashPay contacts — the drain-time
* diagnostic that names the DARK contacts: an established contact with NO
* `dashpayReceivingFunds` account has its receiving addresses in no watched
* script set, so its payments can never match a filter. Under the SDK's
* known re-enqueue asymmetry such a contact can stay dark PERMANENTLY (the
* sweep re-enqueues builds per contact, but a build that keeps failing is
* indistinguishable from one that merely has not run yet), and this report
* is what lets a field log say exactly which contact is stuck.
*
* Pure Room reads, diagnostics only — never load-bearing for the gate.
*/
data class DashPayReceivalCoverage(
/** Distinct contact identities with a RECEIVED contact request (the receival-account universe). */
val establishedContacts: Int,
/** Registered `dashpayReceivingFunds` accounts on the bound wallet. */
val receivalAccounts: Int,
/** Established contacts with NO receival account — the permanently-dark candidates. */
val darkContacts: Int,
/** Up to [DARK_CONTACT_SAMPLE_LIMIT] dark contact identity ids (base58, untruncated). */
val darkContactIdSample: List<String>
) {
companion object {
/** Sample size for [darkContactIdSample] — enough to name the stuck contacts, bounded for one log line. */
const val DARK_CONTACT_SAMPLE_LIMIT = 5
}
}

/**
* The two read-only signals the app-side DIP-15 backfill gate
* ([DashPayBackfillGate]) needs to tell "the coreHeight backfill is still
Expand Down Expand Up @@ -633,6 +662,23 @@ interface DashSdkService {
*/
suspend fun dashPayPendingAccountBuilds(walletIdHex: String): Int?

/**
* Read-only receival-account coverage of the wallet's established
* DashPay contacts — see [DashPayReceivalCoverage] for what it names and
* why. Pure Room reads through [databaseOrNull] (no [ensureStarted], no
* native call, no sweep — the same posture as
* [readDashPayBackfillSignals]); null when the SDK is down, the wallet id
* is malformed, or the read failed. Never throws. Default null so
* read-only fakes stay source-compatible.
*
* @param walletIdHex the bound wallet id ([bindAppWallet]'s return).
* @param ownerIdentityId our 32-byte platform identity id.
*/
suspend fun readDashPayReceivalCoverage(
walletIdHex: String,
ownerIdentityId: ByteArray
): DashPayReceivalCoverage? = null

/**
* Read-only snapshot of the two signals the app-side DIP-15 backfill
* gate reasons over — see [DashPayBackfillSignals] for what each means
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,40 @@ internal suspend fun healIdentityKeys(
)
}

/**
* Fold the SDK's persisted DashPay rows into a [DashPayReceivalCoverage] —
* pure (no Room, no native, no I/O) so the dark-contact set logic is
* host-testable on the plain JVM.
*
* [receivedContactIds] are the contact identity ids of the owner's RECEIVED
* contact requests (the receival-account universe; duplicates collapse), and
* [receivalAccountFriendIds] the `friendIdentityId`s of the wallet's
* registered `dashpayReceivingFunds` accounts. A contact with no matching
* account is DARK: its receiving addresses are in no watched script set, so
* its payments can never match a filter. Ids compare byte-wise; the sample
* is base58 (the same encoding the app's userId and the SDK's identity row
* keys use), in the order the contact rows were supplied — deterministic, so
* consecutive drains name the same stuck contacts.
*/
internal fun computeDashPayReceivalCoverage(
receivedContactIds: List<ByteArray>,
receivalAccountFriendIds: List<ByteArray>,
sampleLimit: Int = DashPayReceivalCoverage.DARK_CONTACT_SAMPLE_LIMIT
): DashPayReceivalCoverage {
val covered = receivalAccountFriendIds.mapTo(HashSet()) { it.toList() }
val contacts = LinkedHashSet<List<Byte>>()
receivedContactIds.forEach { contacts.add(it.toList()) }
val dark = contacts.filter { it !in covered }
return DashPayReceivalCoverage(
establishedContacts = contacts.size,
receivalAccounts = receivalAccountFriendIds.size,
darkContacts = dark.size,
darkContactIdSample = dark.take(sampleLimit).map {
org.bitcoinj.core.Base58.encode(it.toByteArray())
}
)
}

/**
* Default [DashSdkService] implementation — the Phase 3 bootstrap scaffold
* (`docs/kotlin-sdk-migration-plan.md`).
Expand Down Expand Up @@ -997,6 +1031,42 @@ class DashSdkServiceImpl @Inject constructor(
}
}

/**
* See [DashSdkService.readDashPayReceivalCoverage]. Pure Room reads via
* [databaseOrNull] — the same posture as [readDashPayBackfillSignals]:
* no [ensureStarted], no native call, no sweep. Established contacts =
* distinct RECEIVED (`isOutgoing == false`) contact-request senders;
* receival accounts = the wallet's `dashpayReceivingFunds` rows
* ([ACCOUNT_TYPE_TAG_DASHPAY_RECEIVING_FUNDS]), matched by their
* `friendIdentityId`. Never throws — null means "unavailable".
*/
override suspend fun readDashPayReceivalCoverage(
walletIdHex: String,
ownerIdentityId: ByteArray
): DashPayReceivalCoverage? {
return try {
val database = databaseOrNull() ?: return null
val walletId = walletIdFromHex(walletIdHex) ?: return null
val receivedContactIds = database.dashpayDao()
.getContactRequestsByOwner(ownerIdentityId)
.filterNot { it.isOutgoing }
.map { it.contactIdentityId }
val receivalFriendIds = database.accountDao()
.observeByWallet(walletId).first()
.filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_RECEIVING_FUNDS }
.mapNotNull { it.friendIdentityId }
computeDashPayReceivalCoverage(receivedContactIds, receivalFriendIds)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
log.debug(
"failed to read DashPay receival coverage for {}…: {}",
walletIdHex.take(8), e.message
)
null
}
}

/**
* One-shot bring-up; caller holds [lock]. On any failure every
* partially-created resource is torn down and the exception rethrown,
Expand Down
Loading
Loading