Skip to content

Commit 50e1552

Browse files
committed
fix: harden paykit endpoints
1 parent 8a5c573 commit 50e1552

4 files changed

Lines changed: 76 additions & 17 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ import to.bitkit.data.SettingsStore
5858
import to.bitkit.data.backup.VssBackupClientLdk
5959
import to.bitkit.data.keychain.Keychain
6060
import to.bitkit.di.BgDispatcher
61-
import to.bitkit.env.Defaults
6261
import to.bitkit.env.Env
6362
import to.bitkit.ext.getSatsPerVByteFor
6463
import to.bitkit.ext.nowTimestamp
@@ -918,7 +917,7 @@ class LightningRepo @Inject constructor(
918917
suspend fun createInvoice(
919918
amountSats: ULong? = null,
920919
description: String,
921-
expirySeconds: UInt = Defaults.bolt11InvoiceExpirySeconds,
920+
expirySeconds: UInt = 86_400u,
922921
): Result<String> = executeWhenNodeRunning("createInvoice") {
923922
updateGeoBlockState()
924923
runCatching { lightningService.receive(amountSats, description, expirySeconds) }
@@ -927,7 +926,7 @@ class LightningRepo @Inject constructor(
927926
suspend fun createInvoiceMsats(
928927
amountMsats: ULong,
929928
description: String,
930-
expirySeconds: UInt = Defaults.bolt11InvoiceExpirySeconds,
929+
expirySeconds: UInt = 86_400u,
931930
): Result<String> = executeWhenNodeRunning("createInvoiceMsats") {
932931
updateGeoBlockState()
933932
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }

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

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import kotlinx.serialization.Serializable
1111
import kotlinx.serialization.decodeFromString
1212
import kotlinx.serialization.encodeToString
1313
import kotlinx.serialization.json.Json
14+
import org.lightningdevkit.ldknode.Network
1415
import to.bitkit.data.SettingsData
1516
import to.bitkit.data.SettingsStore
1617
import to.bitkit.di.IoDispatcher
@@ -59,6 +60,12 @@ class PublicPaykitRepo @Inject constructor(
5960
companion object {
6061
private val methodIdPattern = Regex("^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$")
6162

63+
private val payloadJson = Json(appJson) {
64+
prettyPrint = false
65+
isLenient = false
66+
encodeDefaults = false
67+
}
68+
6269
private val payablePreferenceOrder = listOf(
6370
MethodId.Bolt11,
6471
MethodId.Lnurl,
@@ -77,7 +84,7 @@ class PublicPaykitRepo @Inject constructor(
7784

7885
val knownMethodId = MethodId.fromRawValue(methodId) ?: return null
7986
val payload = runCatching {
80-
appJson.decodeFromString<PaymentEndpointPayload>(endpointData)
87+
payloadJson.decodeFromString<PaymentEndpointPayload>(endpointData)
8188
}.getOrNull() ?: return null
8289
val value = payload.value.trim()
8390
if (value.isEmpty()) return null
@@ -94,7 +101,7 @@ class PublicPaykitRepo @Inject constructor(
94101
fun serializePayload(value: String): String {
95102
val trimmedValue = value.trim()
96103
if (trimmedValue.isEmpty()) throw PublicPaykitError.InvalidPayload
97-
return Json.encodeToString(PaymentEndpointPayload(value = trimmedValue))
104+
return payloadJson.encodeToString(PaymentEndpointPayload(value = trimmedValue))
98105
}
99106

100107
fun paymentRequest(endpoints: List<Endpoint>): String {
@@ -277,7 +284,8 @@ class PublicPaykitRepo @Inject constructor(
277284
description = "",
278285
expirySeconds = publicBolt11Expiry.inWholeSeconds.toUInt(),
279286
).getOrThrow()
280-
val invoice = (coreService.decode(bolt11) as Scanner.Lightning).invoice
287+
val invoice = (coreService.decode(bolt11) as? Scanner.Lightning)?.invoice
288+
?: throw PublicPaykitError.InvalidPayload
281289
val expiresAtMillis = clock.now().plus(publicBolt11Expiry).toEpochMilliseconds()
282290

283291
settingsStore.update {
@@ -346,18 +354,34 @@ data class Endpoint(
346354
}
347355

348356
enum class MethodId(
349-
val rawValue: String,
357+
private val fixedRawValue: String? = null,
358+
private val onchainEndpoint: String? = null,
350359
val isOnchain: Boolean = false,
351360
val isBitkitManaged: Boolean = false,
352361
) {
353-
Bolt11("btc-lightning-bolt11", isBitkitManaged = true),
354-
Lnurl("btc-lightning-lnurl"),
355-
P2tr("btc-bitcoin-p2tr", isOnchain = true, isBitkitManaged = true),
356-
P2wpkh("btc-bitcoin-p2wpkh", isOnchain = true, isBitkitManaged = true),
357-
P2sh("btc-bitcoin-p2sh", isOnchain = true, isBitkitManaged = true),
358-
P2pkh("btc-bitcoin-p2pkh", isOnchain = true, isBitkitManaged = true),
362+
Bolt11(fixedRawValue = "btc-lightning-bolt11", isBitkitManaged = true),
363+
Lnurl(fixedRawValue = "btc-lightning-lnurl"),
364+
P2tr(onchainEndpoint = "p2tr", isOnchain = true, isBitkitManaged = true),
365+
P2wpkh(onchainEndpoint = "p2wpkh", isOnchain = true, isBitkitManaged = true),
366+
P2sh(onchainEndpoint = "p2sh", isOnchain = true, isBitkitManaged = true),
367+
P2pkh(onchainEndpoint = "p2pkh", isOnchain = true, isBitkitManaged = true),
359368
;
360369

370+
val rawValue: String
371+
get() = rawValueForNetwork(Env.network)
372+
373+
fun rawValueForNetwork(network: Network): String {
374+
fixedRawValue?.let { return it }
375+
val endpoint = checkNotNull(onchainEndpoint)
376+
val rail = when (network) {
377+
Network.BITCOIN -> "bitcoin"
378+
Network.REGTEST -> "regtest"
379+
Network.TESTNET -> "testnet"
380+
Network.SIGNET -> "signet"
381+
}
382+
return "btc-$rail-$endpoint"
383+
}
384+
361385
companion object {
362386
fun fromRawValue(value: String): MethodId? = entries.firstOrNull { it.rawValue == value }
363387
}

app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import to.bitkit.data.SettingsData
4040
import to.bitkit.data.SettingsStore
4141
import to.bitkit.data.backup.VssBackupClientLdk
4242
import to.bitkit.data.keychain.Keychain
43-
import to.bitkit.env.Defaults
4443
import to.bitkit.ext.createChannelDetails
4544
import to.bitkit.ext.of
4645
import to.bitkit.models.CoinSelectionPreference
@@ -201,14 +200,13 @@ class LightningRepoTest : BaseUnitTest() {
201200
lightningService.receive(
202201
sat = 100uL,
203202
description = "test",
204-
expirySecs = Defaults.bolt11InvoiceExpirySeconds,
203+
expirySecs = 86_400u,
205204
)
206205
).thenReturn(testInvoice)
207206

208207
val result = sut.createInvoice(
209208
amountSats = 100uL,
210209
description = "test",
211-
expirySeconds = Defaults.bolt11InvoiceExpirySeconds,
212210
)
213211
assertTrue(result.isSuccess)
214212
assertEquals(testInvoice, result.getOrNull())

app/src/test/java/to/bitkit/repositories/PublicPaykitRepoTest.kt

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.synonym.paykit.FfiPaymentEntry
77
import kotlinx.coroutines.flow.MutableStateFlow
88
import org.junit.Before
99
import org.junit.Test
10+
import org.lightningdevkit.ldknode.Network
1011
import org.mockito.kotlin.any
1112
import org.mockito.kotlin.inOrder
1213
import org.mockito.kotlin.mock
@@ -211,6 +212,25 @@ class PublicPaykitRepoTest : BaseUnitTest() {
211212
verify(pubkyRepo, never()).setPaymentEndpoint(any(), any())
212213
}
213214

215+
@Test
216+
fun `syncCurrentPublishedEndpoints returns InvalidPayload when public bolt11 decode is not lightning`() = test {
217+
walletState.value = WalletState(onchainAddress = "bc1ptest")
218+
whenever(lightningRepo.canReceive()).thenReturn(true)
219+
whenever(
220+
lightningRepo.createInvoice(
221+
amountSats = null,
222+
description = "",
223+
expirySeconds = PUBLIC_BOLT11_EXPIRY_SECONDS,
224+
)
225+
).thenReturn(Result.success("not-lightning"))
226+
whenever(coreService.decode("not-lightning")).thenReturn(mock<Scanner.OnChain>())
227+
228+
val error = sut.syncCurrentPublishedEndpoints().exceptionOrNull()
229+
230+
assertEquals(PublicPaykitError.InvalidPayload, error)
231+
verify(pubkyRepo, never()).setPaymentEndpoint(any(), any())
232+
}
233+
214234
@Test
215235
fun `parseEndpoint accepts Paykit JSON payloads`() {
216236
val endpoint = PublicPaykitRepo.parseEndpoint(
@@ -237,13 +257,23 @@ class PublicPaykitRepoTest : BaseUnitTest() {
237257
@Test
238258
fun `parseEndpoint rejects raw string payloads`() {
239259
val endpoint = PublicPaykitRepo.parseEndpoint(
240-
methodId = "btc-bitcoin-p2wpkh",
260+
methodId = MethodId.P2wpkh.rawValue,
241261
endpointData = "bc1qexampleaddress",
242262
)
243263

244264
assertNull(endpoint)
245265
}
246266

267+
@Test
268+
fun `parseEndpoint rejects lenient JSON payloads`() {
269+
val endpoint = PublicPaykitRepo.parseEndpoint(
270+
methodId = MethodId.P2wpkh.rawValue,
271+
endpointData = """{value:"bc1qexampleaddress"}""",
272+
)
273+
274+
assertNull(endpoint)
275+
}
276+
247277
@Test
248278
fun `parseEndpoint rejects unsupported method ids`() {
249279
val endpoint = PublicPaykitRepo.parseEndpoint(
@@ -355,6 +385,14 @@ class PublicPaykitRepoTest : BaseUnitTest() {
355385
}
356386
}
357387

388+
@Test
389+
fun `onchain method ids use network rail`() {
390+
assertEquals("btc-bitcoin-p2tr", MethodId.P2tr.rawValueForNetwork(Network.BITCOIN))
391+
assertEquals("btc-testnet-p2wpkh", MethodId.P2wpkh.rawValueForNetwork(Network.TESTNET))
392+
assertEquals("btc-regtest-p2sh", MethodId.P2sh.rawValueForNetwork(Network.REGTEST))
393+
assertEquals("btc-signet-p2pkh", MethodId.P2pkh.rawValueForNetwork(Network.SIGNET))
394+
}
395+
358396
@Test
359397
fun `onchainMethodId selects address method id`() {
360398
assertEquals(MethodId.P2tr, PublicPaykitRepo.onchainMethodId("bc1ptest"))

0 commit comments

Comments
 (0)