Skip to content

Commit 0109a58

Browse files
authored
Merge pull request #775 from synonymdev/fix/stale-graph-reset
fix: delete vss network graph using new ffi client
2 parents eeee577 + 6e2fef8 commit 0109a58

File tree

12 files changed

+488
-272
lines changed

12 files changed

+488
-272
lines changed

.claude/commands/pr.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Create a PR on GitHub for the current branch
2+
description: "Create a PR on GitHub, e.g. /pr --draft -- focus on the new wallet sync logic"
33
argument_hint: "[branch] [--dry] [--draft] [-- instructions]"
44
allowed_tools: Bash, Read, Glob, Grep, Write, AskUserQuestion, mcp__github__create_pull_request, mcp__github__list_pull_requests, mcp__github__get_file_contents, mcp__github__issue_read
55
---
@@ -49,6 +49,15 @@ If no base branch argument provided, detect the repo's default branch:
4949
- If instructions reference a specific commit SHA (pattern like `commit [a-f0-9]{7,40}`):
5050
- Read full commit message: `git log -1 --format='%B' <commit_sha>`
5151
- Store instructions for use in description generation
52+
- **Read instruction files from `.ai/pr/`:**
53+
- Scan `.ai/pr/` for markdown files: `ls .ai/pr/*.md 2>/dev/null`
54+
- Read each `.md` file found (using the Read tool)
55+
- Treat their contents as supplementary instructions for description generation, merged with any `--` instructions
56+
- These file-based instructions follow the same priority rules as custom `--` instructions (see Step 6, "Custom Instructions")
57+
- **Scan for media files in `.ai/pr/`:**
58+
- List non-markdown files: `ls .ai/pr/* 2>/dev/null | grep -vE '\.md$'`
59+
- Supported types: `.png`, `.jpg`, `.jpeg`, `.gif`, `.mp4`, `.mov`, `.webm`, `.webp`
60+
- Store the list of found media files with their paths for use in Preview section (Step 6)
5261

5362
### 4. Extract Linked Issues
5463
Scan commits for issue references:
@@ -142,9 +151,22 @@ Example:
142151
143152
**Preview Section (conditional):**
144153
Only include if the PR template (`.github/pull_request_template.md`) contains a `### Preview` heading:
145-
- Create placeholders for media: `IMAGE_1`, `VIDEO_2`, etc.
146-
- Add code comment under each placeholder describing what it should show
147-
- Example: `<!-- VIDEO_1: Record the send flow by scanning a LN invoice and setting amount to 5000 sats -->`
154+
155+
- **If media files were found in `.ai/pr/`:**
156+
- For each media file, add a labeled markdown placeholder in the Preview section
157+
- Use format: `<!-- MEDIA: .ai/pr/filename.ext — Upload this file here via GitHub web UI -->` as a marker
158+
- Add a brief description comment based on the filename (e.g., `pay2blink.mp4` -> "Pay to Blink flow recording")
159+
- Example output:
160+
```
161+
### Preview
162+
163+
<!-- MEDIA: .ai/pr/pay2blink.mp4 — Upload this file here via GitHub web UI -->
164+
```
165+
166+
- **If no media files found in `.ai/pr/`:**
167+
- Fall back to generated placeholders: `IMAGE_1`, `VIDEO_2`, etc.
168+
- Add code comment under each placeholder describing what it should show
169+
- Example: `<!-- VIDEO_1: Record the send flow by scanning a LN invoice and setting amount to 5000 sats -->`
148170
149171
### 7. Save PR Description
150172
Before creating the PR:
@@ -159,6 +181,10 @@ gh pr create --base $base --title "..." --body "..." [--draft]
159181
```
160182
- Add `--draft` flag if draft mode selected
161183
- If actual PR number differs from predicted, rename the saved file
184+
- **If media files exist in `.ai/pr/`:**
185+
- Output the PR edit URL for easy access (append `/edit` to the PR URL)
186+
- Instruct the user to drag-and-drop each media file into the Preview section via the GitHub web UI
187+
- Note: GitHub does not support programmatic media upload to PR bodies
162188

163189
### 9. Output Summary
164190

@@ -185,7 +211,16 @@ Suggested reviewers:
185211
```
186212

187213
**Media TODOs (only if Preview section was included):**
188-
If the PR description includes a Preview section with media placeholders, append:
214+
If the PR description includes a Preview section, append media action items:
215+
216+
If media files were found in `.ai/pr/`:
217+
```
218+
Media to upload (drag-and-drop into Preview section on GitHub):
219+
- .ai/pr/pay2blink.mp4
220+
- .ai/pr/screenshot.png
221+
```
222+
223+
If no media files were found (generated placeholders):
189224
```
190225
## TODOs
191226
- [ ] IMAGE_1: [description]

AGENTS.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,24 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
214214
- ALWAYS add imports instead of inline fully-qualified names
215215
- PREFER to place `@Suppress()` annotations at the narrowest possible scope
216216
- ALWAYS wrap suspend functions in `withContext(bgDispatcher)` if in domain layer, using ctor injected prop `@BgDispatcher private val bgDispatcher: CoroutineDispatcher`
217+
- ALWAYS position companion object at the top of the class
218+
219+
### Device Debugging (adb)
220+
221+
- App IDs per flavor: `to.bitkit.dev` (dev/regtest), `to.bitkit.tnet` (testnet), `to.bitkit` (mainnet)
222+
- ALWAYS use `adb shell "run-as to.bitkit.dev ..."` to access the app's private data directory (debug builds only)
223+
- App files root: `files/` (relative, inside `run-as` context)
224+
- Key paths:
225+
- `files/logs/` — app log files (e.g. `bitkit_2026-02-09_21-04-16.log`)
226+
- `files/bitcoin/wallet0/ldk/` — LDK node storage (graph cache, dumps)
227+
- `files/bitcoin/wallet0/core/` — bitkit-core storage
228+
- `files/datastore/` — DataStore preferences and JSON stores
229+
- To read a file: `adb shell "run-as to.bitkit.dev cat files/logs/bitkit_YYYY-MM-DD_HH-MM-SS.log"`
230+
- To list files: `adb shell "run-as to.bitkit.dev ls -la files/logs/"`
231+
- To find files: `adb shell "run-as to.bitkit.dev find files/ -name '*.log' -o -name '*.txt'"`
232+
- ALWAYS download device files to `.ai/{name}_{timestamp}/` when needed for debugging (e.g. `.ai/logs_1770671066/`)
233+
- To download: `adb shell "run-as to.bitkit.dev cat files/path/to/file" > .ai/folder_timestamp/filename`
234+
- ALWAYS try reading device logs automatically via adb BEFORE asking user to provide log files
217235

218236
### Architecture Guidelines
219237

app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ class VssBackupClient @Inject constructor(
119119
vssStoreIdProvider.clearCache()
120120
Logger.debug("VSS client reset", context = TAG)
121121
}
122+
122123
suspend fun putObject(
123124
key: String,
124125
data: ByteArray,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package to.bitkit.data.backup
2+
3+
import com.synonym.vssclient.KeyVersion
4+
import com.synonym.vssclient.LdkNamespace
5+
import com.synonym.vssclient.VssItem
6+
import com.synonym.vssclient.vssLdkDelete
7+
import com.synonym.vssclient.vssLdkGet
8+
import com.synonym.vssclient.vssLdkListKeys
9+
import com.synonym.vssclient.vssNewLdkClientWithLnurlAuth
10+
import kotlinx.coroutines.CompletableDeferred
11+
import kotlinx.coroutines.CoroutineDispatcher
12+
import kotlinx.coroutines.sync.Mutex
13+
import kotlinx.coroutines.sync.withLock
14+
import kotlinx.coroutines.withContext
15+
import kotlinx.coroutines.withTimeout
16+
import to.bitkit.data.keychain.Keychain
17+
import to.bitkit.di.IoDispatcher
18+
import to.bitkit.env.Env
19+
import to.bitkit.utils.Logger
20+
import javax.inject.Inject
21+
import javax.inject.Singleton
22+
import kotlin.time.Duration.Companion.seconds
23+
24+
@Suppress("TooManyFunctions")
25+
@Singleton
26+
class VssBackupClientLdk @Inject constructor(
27+
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
28+
private val vssStoreIdProvider: VssStoreIdProvider,
29+
private val keychain: Keychain,
30+
) {
31+
companion object {
32+
private const val TAG = "VssBackupClientLdk"
33+
34+
private val NAMESPACES = listOf(
35+
LdkNamespace.Default,
36+
LdkNamespace.Monitors,
37+
LdkNamespace.ArchivedMonitors,
38+
)
39+
}
40+
41+
private var isSetup = CompletableDeferred<Unit>()
42+
private val setupMutex = Mutex()
43+
44+
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
45+
setupMutex.withLock {
46+
runCatching {
47+
if (isSetup.isCompleted && !isSetup.isCancelled) {
48+
runCatching { isSetup.await() }.onSuccess { return@runCatching }
49+
}
50+
51+
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
52+
?: throw MnemonicNotAvailableException()
53+
54+
withTimeout(30.seconds) {
55+
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
56+
vssNewLdkClientWithLnurlAuth(
57+
baseUrl = Env.vssServerUrl,
58+
storeId = vssStoreIdProvider.getVssStoreId(walletIndex),
59+
mnemonic = mnemonic,
60+
passphrase = passphrase,
61+
lnurlAuthServerUrl = Env.lnurlAuthServerUrl,
62+
)
63+
isSetup.complete(Unit)
64+
Logger.info("VSS LDK client setup", context = TAG)
65+
}
66+
}.onFailure {
67+
isSetup.completeExceptionally(it)
68+
Logger.error("VSS LDK client setup error", it, context = TAG)
69+
}
70+
}
71+
}
72+
73+
fun reset() {
74+
synchronized(this) {
75+
isSetup.cancel()
76+
isSetup = CompletableDeferred()
77+
}
78+
Logger.debug("VSS LDK client reset", context = TAG)
79+
}
80+
81+
suspend fun getObject(
82+
key: String,
83+
namespace: LdkNamespace = LdkNamespace.Default,
84+
): Result<VssItem?> = withContext(ioDispatcher) {
85+
isSetup.await()
86+
Logger.verbose("VSS LDK 'getObject' call for '$key'", context = TAG)
87+
runCatching {
88+
vssLdkGet(key = key, namespace = namespace)
89+
}.onSuccess {
90+
if (it == null) {
91+
Logger.verbose("VSS LDK 'getObject' success null for '$key'", context = TAG)
92+
} else {
93+
Logger.verbose("VSS LDK 'getObject' success for '$key'", context = TAG)
94+
}
95+
}.onFailure {
96+
Logger.verbose("VSS LDK 'getObject' error for '$key'", it, context = TAG)
97+
}
98+
}
99+
100+
suspend fun deleteObject(
101+
key: String,
102+
namespace: LdkNamespace = LdkNamespace.Default,
103+
): Result<Boolean> = withContext(ioDispatcher) {
104+
isSetup.await()
105+
Logger.verbose("VSS LDK 'deleteObject' call for '$key'", context = TAG)
106+
runCatching {
107+
vssLdkDelete(key = key, namespace = namespace)
108+
}.onSuccess { wasDeleted ->
109+
if (wasDeleted) {
110+
Logger.verbose("VSS LDK 'deleteObject' success for '$key' - key was deleted", context = TAG)
111+
} else {
112+
Logger.verbose("VSS LDK 'deleteObject' success for '$key' - key did not exist", context = TAG)
113+
}
114+
}.onFailure {
115+
Logger.verbose("VSS LDK 'deleteObject' error for '$key'", it, context = TAG)
116+
}
117+
}
118+
119+
suspend fun listAllKeysTagged(): Result<List<Pair<LdkNamespace, KeyVersion>>> = withContext(ioDispatcher) {
120+
isSetup.await()
121+
Logger.verbose("VSS LDK 'listAllKeysTagged' call", context = TAG)
122+
runCatching {
123+
NAMESPACES.flatMap { ns -> vssLdkListKeys(namespace = ns).map { ns to it } }
124+
}.onSuccess {
125+
Logger.verbose("VSS LDK 'listAllKeysTagged' success - found ${it.size} key(s)", context = TAG)
126+
}.onFailure {
127+
Logger.verbose("VSS LDK 'listAllKeysTagged' error", it, context = TAG)
128+
}
129+
}
130+
}

app/src/main/java/to/bitkit/repositories/BackupRepo.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import to.bitkit.data.CacheStore
3131
import to.bitkit.data.SettingsStore
3232
import to.bitkit.data.WidgetsStore
3333
import to.bitkit.data.backup.VssBackupClient
34+
import to.bitkit.data.backup.VssBackupClientLdk
3435
import to.bitkit.data.resetPin
3536
import to.bitkit.di.IoDispatcher
3637
import to.bitkit.di.json
@@ -78,6 +79,7 @@ class BackupRepo @Inject constructor(
7879
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
7980
private val cacheStore: CacheStore,
8081
private val vssBackupClient: VssBackupClient,
82+
private val vssBackupClientLdk: VssBackupClientLdk,
8183
private val settingsStore: SettingsStore,
8284
private val widgetsStore: WidgetsStore,
8385
private val blocktankRepo: BlocktankRepo,
@@ -107,6 +109,7 @@ class BackupRepo @Inject constructor(
107109
fun reset() {
108110
stopObservingBackups()
109111
vssBackupClient.reset()
112+
vssBackupClientLdk.reset()
110113
}
111114

112115
fun setWiping(isWiping: Boolean) = _isWiping.update { isWiping }
@@ -134,6 +137,8 @@ class BackupRepo @Inject constructor(
134137
onExhausted = { maxAttempts ->
135138
Logger.warn("VSS client setup failed after $maxAttempts attempts", context = TAG)
136139
}
140+
}.onSuccess {
141+
scope.launch { vssBackupClientLdk.setup() }
137142
}
138143
}
139144

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo
4545
import org.lightningdevkit.ldknode.Txid
4646
import to.bitkit.data.CacheStore
4747
import to.bitkit.data.SettingsStore
48-
import to.bitkit.data.backup.VssBackupClient
48+
import to.bitkit.data.backup.VssBackupClientLdk
4949
import to.bitkit.data.keychain.Keychain
5050
import to.bitkit.di.BgDispatcher
5151
import to.bitkit.env.Env
@@ -93,7 +93,7 @@ class LightningRepo @Inject constructor(
9393
private val cacheStore: CacheStore,
9494
private val preActivityMetadataRepo: PreActivityMetadataRepo,
9595
private val connectivityRepo: ConnectivityRepo,
96-
private val vssBackupClient: VssBackupClient,
96+
private val vssBackupClientLdk: VssBackupClientLdk,
9797
) {
9898
private val _lightningState = MutableStateFlow(LightningState())
9999
val lightningState = _lightningState.asStateFlow()
@@ -325,19 +325,20 @@ class LightningRepo @Inject constructor(
325325
updateGeoBlockState()
326326
refreshChannelCache()
327327

328-
// Validate network graph has trusted peers (RGS cache can become stale)
329-
if (shouldValidateGraph && !lightningService.validateNetworkGraph()) {
328+
if (shouldValidateGraph && !lightningService.aresRequiredPeersInNetworkGraph()) {
330329
Logger.warn("Network graph is stale, resetting and restarting...", context = TAG)
330+
331331
lightningService.stop()
332332
lightningService.resetNetworkGraph(walletIndex)
333-
// Also clear stale graph from VSS to prevent fallback restoration
333+
334334
runCatching {
335-
vssBackupClient.setup(walletIndex).getOrThrow()
336-
vssBackupClient.deleteObject("network_graph").getOrThrow()
337-
Logger.info("Cleared stale network graph from VSS", context = TAG)
335+
vssBackupClientLdk.setup(walletIndex).getOrThrow()
336+
vssBackupClientLdk.deleteObject("network_graph").getOrThrow()
337+
Logger.info("Cleared stale network graph from VSS (first delete)", context = TAG)
338338
}.onFailure {
339-
Logger.warn("Failed to clear graph from VSS", it, context = TAG)
339+
Logger.warn("Failed to clear graph from VSS (first delete)", it, context = TAG)
340340
}
341+
341342
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Stopped) }
342343
shouldRestartForGraphReset = true
343344
return@withLock Result.success(Unit)

0 commit comments

Comments
 (0)