From 9e0ceaee3d1df7bd258373a72a4eecb9855554b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:35:27 -0700 Subject: [PATCH 1/4] Merge release/lightspark-sdk-v0.15.0 into main (#187) * lnurlp fallback * annotation and tests * fallback sender side * fix serialization * unused import * comments * fixes * Try to fix docs publish job my running on main * [gha] Use environment for doc-publish * Fix sender vasp utxo callback * Switch to a full 64bit nonce * Use json for parsing in demo vasp (#163) * Update the demo VASP's SDK version to 0.8.0 * Update the demo VASP's SDK version to 0.8.1 * Accept gzip responses, compress request with deflate (#172) Install `ContentEncoding` plugin to handle gzip responses. Compress requests larger than 1K using `Deflater`. Change `addSigningDataIfNeeded()` to return serialised bytes so that we only serialise once. * Regenerate SDK (#173) * Regenerate SDK * no op vls * Update macos environment version (#175) * Uma data visibility (#174) * fix typo (#176) * encode hex (#177) * Bump lightspark-sdk to version 0.14.0 * Load signing key * Revert "Merge release/lightspark-sdk-v0.14.0 into develop" (#181) * Bump lightspark-sdk to version 0.14.0 * update readme (#184) * Update invoice creator (#185) * Bump lightspark-sdk to version 0.15.0 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: shreyav Co-authored-by: Jeremy Klein Co-authored-by: Michael Gorven Co-authored-by: Michael Gorven Co-authored-by: runner Co-authored-by: runner Co-authored-by: runner --- RELEASING.md | 4 +- gradle/libs.versions.toml | 2 +- lightspark-sdk/README.md | 4 +- lightspark-sdk/gradle.properties | 2 +- .../LightsparkClientUmaInvoiceCreator.kt | 38 +++++++++++++++++-- .../src/main/kotlin/com/lightspark/Vasp2.kt | 8 +++- 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index d5cb2e0c..9157bcf9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,8 +7,8 @@ For example, to cut a release branch for version `1.0.0` of the `wallet-sdk` mod the following from the `develop` branch: ```bash -git checkout -b release/wallet-sdk-1.0.0 -git push -u origin release/wallet-sdk-1.0.0 +git checkout -b release/wallet-sdk-v1.0.0 +git push -u origin release/wallet-sdk-v1.0.0 ``` Alternatively, you can create the new branch from the github UI. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f86dae1..1f971d5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ ktlint = "11.3.1" ktor = "2.3.7" lightsparkCore = "0.6.0" lightsparkCrypto = "0.6.0" -uma = "1.1.1" +uma = "1.2.1" mavenPublish = "0.25.2" mockitoCore = "5.5.0" taskTree = "2.1.1" diff --git a/lightspark-sdk/README.md b/lightspark-sdk/README.md index 2dbcfb8f..6a6abacb 100644 --- a/lightspark-sdk/README.md +++ b/lightspark-sdk/README.md @@ -17,14 +17,14 @@ Start by installing the SDK from maven: **build.gradle:** ```groovy dependencies { - implementation "com.lightspark:lightspark-sdk:0.14.0" + implementation "com.lightspark:lightspark-sdk:0.15.0" } ``` or with **build.gradle.kts:** ```kotlin dependencies { - implementation("com.lightspark:lightspark-sdk:0.14.0") + implementation("com.lightspark:lightspark-sdk:0.15.0") } ``` diff --git a/lightspark-sdk/gradle.properties b/lightspark-sdk/gradle.properties index 71900f2e..34a2d73d 100644 --- a/lightspark-sdk/gradle.properties +++ b/lightspark-sdk/gradle.properties @@ -1,7 +1,7 @@ GROUP=com.lightspark POM_ARTIFACT_ID=lightspark-sdk # Don't bump this manually. Run `scripts/versions.main.kt ` to bump the version instead. -VERSION_NAME=0.14.0 +VERSION_NAME=0.15.0 POM_DESCRIPTION=The Lightspark API SDK for Kotlin and Java. POM_INCEPTION_YEAR=2023 diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/LightsparkClientUmaInvoiceCreator.kt b/umaserverdemo/src/main/kotlin/com/lightspark/LightsparkClientUmaInvoiceCreator.kt index e2ea906c..4a041a5e 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/LightsparkClientUmaInvoiceCreator.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/LightsparkClientUmaInvoiceCreator.kt @@ -8,14 +8,38 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.future import me.uma.UmaInvoiceCreator -class LightsparkClientUmaInvoiceCreator( +/** + * Creates UMA invoices using the Lightspark client. + * + * @constructor Creates an instance of [LightsparkClientUmaInvoiceCreator]. + * + * @param client The [LightsparkCoroutinesClient] used to create invoices. + * @param nodeId The ID of the node for which to create the invoice. + * @param expirySecs The number of seconds before the invoice expires. + * @param enableUmaAnalytics A flag indicating whether UMA analytics should be enabled. If `true`, + * the receiver identifier will be hashed using a monthly-rotated seed and used for anonymized + * analysis. + * @param signingPrivateKey Optional, the receiver's signing private key. Used to hash the receiver + * identifier if UMA analytics is enabled. + */ +class LightsparkClientUmaInvoiceCreator @JvmOverloads constructor( private val client: LightsparkCoroutinesClient, private val nodeId: String, private val expirySecs: Int, + private val enableUmaAnalytics: Boolean = false, + private val signingPrivateKey: ByteArray? = null, ) : UmaInvoiceCreator { private val coroutineScope = CoroutineScope(Dispatchers.IO) - constructor(apiClientId: String, apiClientSecret: String, nodeId: String, expirySecs: Int) : this( + @JvmOverloads + constructor( + apiClientId: String, + apiClientSecret: String, + nodeId: String, + expirySecs: Int, + enableUmaAnalytics: Boolean = false, + signingPrivateKey: ByteArray? = null, + ) : this( LightsparkCoroutinesClient( ClientConfig( authProvider = AccountApiTokenAuthProvider(apiClientId, apiClientSecret), @@ -23,9 +47,15 @@ class LightsparkClientUmaInvoiceCreator( ), nodeId, expirySecs, + enableUmaAnalytics, + signingPrivateKey, ) - override fun createUmaInvoice(amountMsats: Long, metadata: String) = coroutineScope.future { - client.createUmaInvoice(nodeId, amountMsats, metadata, expirySecs).data.encodedPaymentRequest + override fun createUmaInvoice(amountMsats: Long, metadata: String, receiverIdentifier: String?) = coroutineScope.future { + if (enableUmaAnalytics && signingPrivateKey != null) { + client.createUmaInvoice(nodeId, amountMsats, metadata, expirySecs, signingPrivateKey, receiverIdentifier) + } else { + client.createUmaInvoice(nodeId, amountMsats, metadata, expirySecs) + }.data.encodedPaymentRequest } } diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt index d2a0e296..61fd37c9 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt @@ -246,7 +246,13 @@ class Vasp2( val response = try { uma.getPayReqResponse( query = request, - invoiceCreator = LightsparkClientUmaInvoiceCreator(client, config.nodeID, expirySecs), + invoiceCreator = LightsparkClientUmaInvoiceCreator( + client = client, + nodeId = config.nodeID, + expirySecs = expirySecs, + enableUmaAnalytics = true, + signingPrivateKey = config.umaSigningPrivKey, + ), metadata = getEncodedMetadata(), receivingCurrencyCode = receivingCurrency.code, receivingCurrencyDecimals = receivingCurrency.decimals, From 3cc7db6ffe6c8c97ddeb4f6388c143f3096c2b7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:48:22 -0700 Subject: [PATCH 2/4] Merge release/lightspark-sdk-v0.15.1 into main (#191) * lnurlp fallback * annotation and tests * fallback sender side * fix serialization * unused import * comments * fixes * Try to fix docs publish job my running on main * [gha] Use environment for doc-publish * Fix sender vasp utxo callback * Switch to a full 64bit nonce * Use json for parsing in demo vasp (#163) * Update the demo VASP's SDK version to 0.8.0 * Update the demo VASP's SDK version to 0.8.1 * Accept gzip responses, compress request with deflate (#172) Install `ContentEncoding` plugin to handle gzip responses. Compress requests larger than 1K using `Deflater`. Change `addSigningDataIfNeeded()` to return serialised bytes so that we only serialise once. * Regenerate SDK (#173) * Regenerate SDK * no op vls * Update macos environment version (#175) * Uma data visibility (#174) * fix typo (#176) * encode hex (#177) * Bump lightspark-sdk to version 0.14.0 * Load signing key * Revert "Merge release/lightspark-sdk-v0.14.0 into develop" (#181) * Bump lightspark-sdk to version 0.14.0 * update readme (#184) * Update invoice creator (#185) * Bump lightspark-sdk to version 0.15.0 * Remove default null on PayUmaInvoice (#189) * Bump lightspark-sdk to version 0.15.1 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: shreyav Co-authored-by: Jeremy Klein Co-authored-by: Michael Gorven Co-authored-by: Michael Gorven Co-authored-by: runner Co-authored-by: runner Co-authored-by: runner Co-authored-by: runner --- lightspark-sdk/README.md | 4 ++-- lightspark-sdk/gradle.properties | 2 +- .../kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lightspark-sdk/README.md b/lightspark-sdk/README.md index 6a6abacb..73a99dd2 100644 --- a/lightspark-sdk/README.md +++ b/lightspark-sdk/README.md @@ -17,14 +17,14 @@ Start by installing the SDK from maven: **build.gradle:** ```groovy dependencies { - implementation "com.lightspark:lightspark-sdk:0.15.0" + implementation "com.lightspark:lightspark-sdk:0.15.1" } ``` or with **build.gradle.kts:** ```kotlin dependencies { - implementation("com.lightspark:lightspark-sdk:0.15.0") + implementation("com.lightspark:lightspark-sdk:0.15.1") } ``` diff --git a/lightspark-sdk/gradle.properties b/lightspark-sdk/gradle.properties index 34a2d73d..51a47559 100644 --- a/lightspark-sdk/gradle.properties +++ b/lightspark-sdk/gradle.properties @@ -1,7 +1,7 @@ GROUP=com.lightspark POM_ARTIFACT_ID=lightspark-sdk # Don't bump this manually. Run `scripts/versions.main.kt ` to bump the version instead. -VERSION_NAME=0.15.0 +VERSION_NAME=0.15.1 POM_DESCRIPTION=The Lightspark API SDK for Kotlin and Java. POM_INCEPTION_YEAR=2023 diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt index 972cf45f..22ae6948 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt @@ -9,7 +9,7 @@ const val PayUmaInvoiceMutation = """ ${'$'}timeout_secs: Int! ${'$'}maximum_fees_msats: Long! ${'$'}amount_msats: Long - ${'$'}sender_hash: String = null + ${'$'}sender_hash: String ) { pay_uma_invoice( input: { From 97aa6e86ec767ed6ee9eccfbb0c1a568af76b14e Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 12 Feb 2025 13:01:46 -0800 Subject: [PATCH 3/4] Release/lightspark sdk v0.19.0 (#216) Merging release PR --------- Co-authored-by: Matt Davis Co-authored-by: Matt Davis Co-authored-by: Joel Weinberger Co-authored-by: Jason Wang Co-authored-by: runner --- .github/workflows/core-build.yaml | 2 +- .github/workflows/core-publish.yaml | 2 +- .github/workflows/crypto-build.yaml | 2 +- .github/workflows/crypto-publish.yaml | 2 +- .github/workflows/docs-publish.yaml | 2 +- .github/workflows/lightspark-sdk-build.yaml | 2 +- .github/workflows/lightspark-sdk-publish.yaml | 2 +- .github/workflows/release-branch-cut.yaml | 2 +- .github/workflows/wallet-build.yaml | 2 +- .github/workflows/wallet-publish.yaml | 2 +- SECURITY.md | 13 ++ .../sdk/core/requester/Requester.kt | 7 + gradle/libs.versions.toml | 2 +- lightspark-sdk/README.md | 4 +- lightspark-sdk/gradle.properties | 2 +- .../lightspark/sdk/LightsparkFuturesClient.kt | 48 ++++- .../sdk/LightsparkCoroutinesClient.kt | 57 +++++- .../lightspark/sdk/LightsparkSyncClient.kt | 63 +++++- .../OutgoingPaymentForIdempotencyKey.kt | 20 ++ .../com/lightspark/sdk/graphql/PayInvoice.kt | 2 + .../lightspark/sdk/graphql/PayUmaInvoice.kt | 2 + .../sdk/graphql/RequestWithdrawal.kt | 2 + .../com/lightspark/sdk/graphql/SendPayment.kt | 2 + .../sdk/model/CreateUmaInvoiceInput.kt | 5 + .../com/lightspark/sdk/model/CurrencyUnit.kt | 3 + .../lightspark/{Vasp2.kt => ReceivingVasp.kt} | 177 +++++++++++++++- .../lightspark/{Vasp1.kt => SendingVasp.kt} | 193 +++++++++++++++++- ...estCache.kt => SendingVaspRequestCache.kt} | 35 +++- .../kotlin/com/lightspark/plugins/Routing.kt | 43 +++- .../sdk/wallet/model/CurrencyUnit.kt | 3 + 30 files changed, 630 insertions(+), 73 deletions(-) create mode 100644 SECURITY.md create mode 100644 lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/OutgoingPaymentForIdempotencyKey.kt rename umaserverdemo/src/main/kotlin/com/lightspark/{Vasp2.kt => ReceivingVasp.kt} (66%) rename umaserverdemo/src/main/kotlin/com/lightspark/{Vasp1.kt => SendingVasp.kt} (72%) rename umaserverdemo/src/main/kotlin/com/lightspark/{Vasp1RequestCache.kt => SendingVaspRequestCache.kt} (62%) diff --git a/.github/workflows/core-build.yaml b/.github/workflows/core-build.yaml index 0e610f34..df70df3b 100644 --- a/.github/workflows/core-build.yaml +++ b/.github/workflows/core-build.yaml @@ -19,7 +19,7 @@ on: jobs: build: - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/core-publish.yaml b/.github/workflows/core-publish.yaml index 851af93e..3404b054 100644 --- a/.github/workflows/core-publish.yaml +++ b/.github/workflows/core-publish.yaml @@ -7,7 +7,7 @@ on: jobs: publish-core-sdk: if: ${{ startsWith(github.event.release.tag_name, 'core-') }} - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/crypto-build.yaml b/.github/workflows/crypto-build.yaml index a68c8fc3..0ccbac80 100644 --- a/.github/workflows/crypto-build.yaml +++ b/.github/workflows/crypto-build.yaml @@ -21,7 +21,7 @@ jobs: build: # No point in running this build on a core cut because it will fail until the deploy is done. if: github.event.base_ref == null || !startsWith(github.event.base_ref, 'release/core-') - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/crypto-publish.yaml b/.github/workflows/crypto-publish.yaml index 76f404d7..f8154c8e 100644 --- a/.github/workflows/crypto-publish.yaml +++ b/.github/workflows/crypto-publish.yaml @@ -7,7 +7,7 @@ on: jobs: publish-crypto-sdk: if: ${{ startsWith(github.event.release.tag_name, 'crypto-') }} - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/docs-publish.yaml b/.github/workflows/docs-publish.yaml index 2d105114..542adfff 100644 --- a/.github/workflows/docs-publish.yaml +++ b/.github/workflows/docs-publish.yaml @@ -16,7 +16,7 @@ on: jobs: publish-docs: if: ${{ github.event.workflow_run.conclusion == 'success' }} - runs-on: macos-12 + runs-on: macos-13 environment: "docs" permissions: id-token: write diff --git a/.github/workflows/lightspark-sdk-build.yaml b/.github/workflows/lightspark-sdk-build.yaml index a81db4cd..e7d54771 100644 --- a/.github/workflows/lightspark-sdk-build.yaml +++ b/.github/workflows/lightspark-sdk-build.yaml @@ -23,7 +23,7 @@ jobs: build: # No point in running this build on a core cut because it will fail until the deploy is done. if: github.event.base_ref == null || !startsWith(github.event.base_ref, 'release/core-') - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/lightspark-sdk-publish.yaml b/.github/workflows/lightspark-sdk-publish.yaml index d8f4a5c8..99333252 100644 --- a/.github/workflows/lightspark-sdk-publish.yaml +++ b/.github/workflows/lightspark-sdk-publish.yaml @@ -7,7 +7,7 @@ on: jobs: publish-lightspark-sdk: if: ${{ startsWith(github.event.release.tag_name, 'lightspark-sdk-') }} - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/release-branch-cut.yaml b/.github/workflows/release-branch-cut.yaml index e741d034..bd40cba4 100644 --- a/.github/workflows/release-branch-cut.yaml +++ b/.github/workflows/release-branch-cut.yaml @@ -7,7 +7,7 @@ on: jobs: bump-versions: - runs-on: macos-12 + runs-on: macos-13 permissions: contents: write pull-requests: write diff --git a/.github/workflows/wallet-build.yaml b/.github/workflows/wallet-build.yaml index a8b8769e..b10aa2da 100644 --- a/.github/workflows/wallet-build.yaml +++ b/.github/workflows/wallet-build.yaml @@ -23,7 +23,7 @@ jobs: build: # No point in running this build on a core cut because it will fail until the deploy is done. if: github.event.base_ref == null || !startsWith(github.event.base_ref, 'release/core-') - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/wallet-publish.yaml b/.github/workflows/wallet-publish.yaml index ce4b30c0..53c08fe7 100644 --- a/.github/workflows/wallet-publish.yaml +++ b/.github/workflows/wallet-publish.yaml @@ -7,7 +7,7 @@ on: jobs: publish-wallet-sdk: if: ${{ startsWith(github.event.release.tag_name, 'wallet-sdk-') }} - runs-on: macos-12 + runs-on: macos-13 steps: - uses: actions/checkout@v3 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..17083d31 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +At Lightspark, we take security very seriously. We appreciate responsible +disclosure of security issues and will endeavor to acknowledge your +contributions. + + +## Reporting a Vulnerability + +If you believe you have found a security issue or problem, please email us +at security@lightspark.com with as much detail as possible. Alternatively, +you may report a security issue through the GitHub Security Advisory +["Report a Vulnerability" tab](https://github.com/lightsparkdev/kotlin-sdk/security/advisories/new). diff --git a/core/src/commonMain/kotlin/com/lightspark/sdk/core/requester/Requester.kt b/core/src/commonMain/kotlin/com/lightspark/sdk/core/requester/Requester.kt index 1d055f77..8bd7eaab 100644 --- a/core/src/commonMain/kotlin/com/lightspark/sdk/core/requester/Requester.kt +++ b/core/src/commonMain/kotlin/com/lightspark/sdk/core/requester/Requester.kt @@ -10,6 +10,7 @@ import com.lightspark.sdk.core.crypto.NodeKeyCache import com.lightspark.sdk.core.crypto.nextLong import com.lightspark.sdk.core.util.getPlatform import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.headers @@ -52,6 +53,12 @@ class Requester constructor( gzip() // Switch to deflate() when https://youtrack.jetbrains.com/issue/KTOR-6999 is fixed } install(WebSockets) + install(HttpTimeout) { + // See https://ktor.io/docs/client-timeout.html for more info. + requestTimeoutMillis = 20000 + connectTimeoutMillis = 5000 + socketTimeoutMillis = 10000 + } } private val userAgent = "lightspark-kotlin-sdk/${LightsparkCoreConfig.VERSION} ${getPlatform().platformName}/${getPlatform().version}" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f971d5b..8bab457e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ ktlint = "11.3.1" ktor = "2.3.7" lightsparkCore = "0.6.0" lightsparkCrypto = "0.6.0" -uma = "1.2.1" +uma = "1.3.0" mavenPublish = "0.25.2" mockitoCore = "5.5.0" taskTree = "2.1.1" diff --git a/lightspark-sdk/README.md b/lightspark-sdk/README.md index 3076690a..e2d84790 100644 --- a/lightspark-sdk/README.md +++ b/lightspark-sdk/README.md @@ -17,14 +17,14 @@ Start by installing the SDK from maven: **build.gradle:** ```groovy dependencies { - implementation "com.lightspark:lightspark-sdk:0.18.0" + implementation "com.lightspark:lightspark-sdk:0.19.0" } ``` or with **build.gradle.kts:** ```kotlin dependencies { - implementation("com.lightspark:lightspark-sdk:0.18.0") + implementation("com.lightspark:lightspark-sdk:0.19.0") } ``` diff --git a/lightspark-sdk/gradle.properties b/lightspark-sdk/gradle.properties index ec5ef4a8..27033c36 100644 --- a/lightspark-sdk/gradle.properties +++ b/lightspark-sdk/gradle.properties @@ -1,7 +1,7 @@ GROUP=com.lightspark POM_ARTIFACT_ID=lightspark-sdk # Don't bump this manually. Run `scripts/versions.main.kt ` to bump the version instead. -VERSION_NAME=0.18.0 +VERSION_NAME=0.19.0 POM_DESCRIPTION=The Lightspark API SDK for Kotlin and Java. POM_INCEPTION_YEAR=2023 diff --git a/lightspark-sdk/src/commonJvmAndroidMain/kotlin/com/lightspark/sdk/LightsparkFuturesClient.kt b/lightspark-sdk/src/commonJvmAndroidMain/kotlin/com/lightspark/sdk/LightsparkFuturesClient.kt index 9d76ef88..de40f052 100644 --- a/lightspark-sdk/src/commonJvmAndroidMain/kotlin/com/lightspark/sdk/LightsparkFuturesClient.kt +++ b/lightspark-sdk/src/commonJvmAndroidMain/kotlin/com/lightspark/sdk/LightsparkFuturesClient.kt @@ -222,6 +222,8 @@ class LightsparkFuturesClient(config: ClientConfig) { * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. * @param amountMsats The amount to pay in milli-satoshis. Defaults to the full amount of the invoice. * @param timeoutSecs The number of seconds to wait for the payment to complete. Defaults to 60. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return The payment details. */ @JvmOverloads @@ -231,6 +233,7 @@ class LightsparkFuturesClient(config: ClientConfig) { maxFeesMsats: Long, amountMsats: Long? = null, timeoutSecs: Int = 60, + idempotencyKey: String? = null, ): CompletableFuture = coroutineScope.future { coroutinesClient.payInvoice( @@ -239,6 +242,7 @@ class LightsparkFuturesClient(config: ClientConfig) { maxFeesMsats, amountMsats, timeoutSecs, + idempotencyKey, ) } @@ -257,6 +261,8 @@ class LightsparkFuturesClient(config: ClientConfig) { * @param signingPrivateKey The sender's signing private key. Used to hash the sender identifier. * @param senderIdentifier Optional identifier of the sender. If provided, this will be hashed using a * monthly-rotated seed and used for anonymized analysis. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return The payment details. */ @JvmOverloads @@ -269,6 +275,7 @@ class LightsparkFuturesClient(config: ClientConfig) { timeoutSecs: Int = 60, signingPrivateKey: ByteArray? = null, senderIdentifier: String? = null, + idempotencyKey: String? = null, ): CompletableFuture = coroutineScope.future { coroutinesClient.payUmaInvoice( @@ -279,6 +286,7 @@ class LightsparkFuturesClient(config: ClientConfig) { timeoutSecs, signingPrivateKey, senderIdentifier, + idempotencyKey, ) } @@ -432,14 +440,26 @@ class LightsparkFuturesClient(config: ClientConfig) { * @param amountSats The amount of funds to withdraw in SATOSHI. * @param bitcoinAddress The Bitcoin address to withdraw funds to. * @param mode The mode to use for the withdrawal. See `WithdrawalMode` for more information. + * @param idempotencyKey An optional key to ensure idempotency of the withdrawal. If provided, the same result will + * be returned for the same idempotency key without triggering a new withdrawal. */ + @JvmOverloads fun requestWithdrawal( nodeId: String, amountSats: Long, bitcoinAddress: String, mode: WithdrawalMode, + idempotencyKey: String? = null, ): CompletableFuture = - coroutineScope.future { coroutinesClient.requestWithdrawal(nodeId, amountSats, bitcoinAddress, mode) } + coroutineScope.future { + coroutinesClient.requestWithdrawal( + nodeId, + amountSats, + bitcoinAddress, + mode, + idempotencyKey, + ) + } /** * Sends a payment directly to a node on the Lightning Network through the public key of the node without an invoice. @@ -451,6 +471,8 @@ class LightsparkFuturesClient(config: ClientConfig) { * As guidance, a maximum fee of 15 basis points should make almost all transactions succeed. For example, * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. * @param timeoutSecs The timeout in seconds that we will try to make the payment. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return An `OutgoingPayment` object if the payment was successful, or throws if the payment failed. * @throws LightsparkException if the payment failed. */ @@ -461,6 +483,7 @@ class LightsparkFuturesClient(config: ClientConfig) { amountMsats: Long, maxFeesMsats: Long, timeoutSecs: Int = 60, + idempotencyKey: String? = null, ): CompletableFuture = coroutineScope.future { coroutinesClient.sendPayment( payerNodeId, @@ -468,6 +491,7 @@ class LightsparkFuturesClient(config: ClientConfig) { amountMsats, maxFeesMsats, timeoutSecs, + idempotencyKey, ) } @@ -579,27 +603,39 @@ class LightsparkFuturesClient(config: ClientConfig) { /** * fetch outgoing payments for a given payment hash - * + * * @param paymentHash the payment hash of the invoice for which to fetch the outgoing payments * @param transactionStatuses the transaction statuses to filter the payments by. If null, all payments will be returned. */ @Throws(LightsparkException::class, LightsparkAuthenticationException::class) fun getOutgoingPaymentsForPaymentHash( paymentHash: String, - transactionStatuses: List? = null + transactionStatuses: List? = null, ): CompletableFuture> = coroutineScope.future { coroutinesClient.getOutgoingPaymentForPaymentHash(paymentHash, transactionStatuses) } + /** + * Fetch outgoing payment for a given idempotency key + * + * @param idempotencyKey The idempotency key used when creating the payment. + */ + @Throws(LightsparkException::class, LightsparkAuthenticationException::class) + fun getOutgoingPaymentForIdempotencyKey( + idempotencyKey: String, + ): CompletableFuture = coroutineScope.future { + coroutinesClient.getOutgoingPaymentForIdempotencyKey(idempotencyKey) + } + /** * fetch invoice for a given payments hash - * + * * @param paymentHash the payment hash of the invoice for which to fetch the outgoing payments * @param transactionStatuses the transaction statuses to filter the payments by. If null, all payments will be returned. */ @Throws(LightsparkException::class, LightsparkAuthenticationException::class) fun getInvoiceForPaymentHash( - paymentHash: String + paymentHash: String, ): CompletableFuture = coroutineScope.future { coroutinesClient.getInvoiceForPaymentHash(paymentHash) } @@ -623,7 +659,7 @@ class LightsparkFuturesClient(config: ClientConfig) { @Throws(LightsparkException::class, LightsparkAuthenticationException::class) fun getIncomingPaymentsForInvoice( invoiceId: String, - transactionStatuses: List? = null + transactionStatuses: List? = null, ): CompletableFuture> = coroutineScope.future { coroutinesClient.getIncomingPaymentsForInvoice(invoiceId, transactionStatuses) } diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt index 6eaf367c..e8b3abe3 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkCoroutinesClient.kt @@ -347,6 +347,8 @@ class LightsparkCoroutinesClient private constructor( * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. * @param amountMsats The amount to pay in milli-satoshis. Defaults to the full amount of the invoice. * @param timeoutSecs The number of seconds to wait for the payment to complete. Defaults to 60. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return The payment details. */ @JvmOverloads @@ -356,6 +358,7 @@ class LightsparkCoroutinesClient private constructor( maxFeesMsats: Long, amountMsats: Long? = null, timeoutSecs: Int = 60, + idempotencyKey: String? = null, ): OutgoingPayment { requireValidAuth() return executeQuery( @@ -367,6 +370,7 @@ class LightsparkCoroutinesClient private constructor( add("timeout_secs", timeoutSecs) add("maximum_fees_msats", maxFeesMsats) amountMsats?.let { add("amount_msats", amountMsats) } + idempotencyKey?.let { add("idempotency_key", idempotencyKey) } }, signingNodeId = nodeId, ) { @@ -392,6 +396,8 @@ class LightsparkCoroutinesClient private constructor( * @param signingPrivateKey The sender's signing private key. Used to hash the sender identifier. * @param senderIdentifier Optional identifier of the sender. If provided, this will be hashed using a * monthly-rotated seed and used for anonymized analysis. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return The payment details. */ @JvmOverloads @@ -404,6 +410,7 @@ class LightsparkCoroutinesClient private constructor( timeoutSecs: Int = 60, signingPrivateKey: ByteArray? = null, senderIdentifier: String? = null, + idempotencyKey: String? = null, ): OutgoingPayment { requireValidAuth() @@ -425,6 +432,7 @@ class LightsparkCoroutinesClient private constructor( add("maximum_fees_msats", maxFeesMsats) amountMsats?.let { add("amount_msats", amountMsats) } senderHash?.let { add("sender_hash", senderHash) } + idempotencyKey?.let { add("idempotency_key", idempotencyKey) } }, signingNodeId = nodeId, ) { @@ -735,7 +743,7 @@ class LightsparkCoroutinesClient private constructor( suspend fun fundNode( nodeId: String, amountSats: Long?, - fundingAddress: String? = null + fundingAddress: String? = null, ): CurrencyAmount { requireValidAuth() return executeQuery( @@ -744,7 +752,7 @@ class LightsparkCoroutinesClient private constructor( { add("node_id", nodeId) amountSats?.let { add("amount_sats", it) } - fundingAddress?.let { add("funding_address", it)} + fundingAddress?.let { add("funding_address", it) } }, signingNodeId = nodeId, ) { @@ -767,12 +775,16 @@ class LightsparkCoroutinesClient private constructor( * @param amountSats The amount of funds to withdraw in SATOSHI. * @param bitcoinAddress The Bitcoin address to withdraw funds to. * @param mode The mode to use for the withdrawal. See `WithdrawalMode` for more information. + * @param idempotencyKey An optional key to ensure idempotency of the withdrawal. If provided, the same result will + * be returned for the same idempotency key without triggering a new withdrawal. */ + @JvmOverloads suspend fun requestWithdrawal( nodeId: String, amountSats: Long, bitcoinAddress: String, mode: WithdrawalMode, + idempotencyKey: String? = null, ): WithdrawalRequest { requireValidAuth() return executeQuery( @@ -783,6 +795,7 @@ class LightsparkCoroutinesClient private constructor( add("amount_sats", amountSats) add("bitcoin_address", bitcoinAddress) add("withdrawal_mode", serializerFormat.encodeToJsonElement(mode)) + idempotencyKey?.let { add("idempotency_key", idempotencyKey) } }, signingNodeId = nodeId, ) { @@ -805,15 +818,19 @@ class LightsparkCoroutinesClient private constructor( * As guidance, a maximum fee of 15 basis points should make almost all transactions succeed. For example, * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. * @param timeoutSecs The timeout in seconds that we will try to make the payment. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return An `OutgoingPayment` object if the payment was successful, or throws if the payment failed. * @throws LightsparkException if the payment failed. */ + @JvmOverloads suspend fun sendPayment( payerNodeId: String, destinationPublicKey: String, amountMsats: Long, maxFeesMsats: Long, timeoutSecs: Int = 60, + idempotencyKey: String? = null, ): OutgoingPayment { requireValidAuth() return executeQuery( @@ -825,6 +842,7 @@ class LightsparkCoroutinesClient private constructor( add("amount_msats", amountMsats) add("timeout_secs", timeoutSecs) add("maximum_fees_msats", maxFeesMsats) + idempotencyKey?.let { add("idempotency_key", idempotencyKey) } }, signingNodeId = payerNodeId, ) { @@ -1044,7 +1062,7 @@ class LightsparkCoroutinesClient private constructor( /** * fetch outgoing payments for a given payment hash - * + * * @param paymentHash the payment hash of the invoice for which to fetch the outgoing payments * @param transactionStatuses the transaction statuses to filter the payments by. If null, all payments will be returned. */ @@ -1072,13 +1090,40 @@ class LightsparkCoroutinesClient private constructor( ) } + /** + * Fetch outgoing payment for a given idempotency key + * + * @param idempotencyKey The idempotency key used when creating the payment. + */ + suspend fun getOutgoingPaymentForIdempotencyKey( + idempotencyKey: String, + ): OutgoingPayment? { + requireValidAuth() + return executeQuery( + Query( + OutgoingPaymentForIdempotencyKeyQuery, + { + add("idempotency_key", idempotencyKey) + }, + ) { + val outputJson = + requireNotNull(it["outgoing_payment_for_idempotency_key"]) { "No payment output found in response" } + val paymentJson = outputJson.jsonObject["payment"] + if (paymentJson == null) { + return@Query null + } + serializerFormat.decodeFromJsonElement(paymentJson) + }, + ) + } + /** * fetch invoice for a given payment hash - * + * * @param paymentHash the payment hash of the invoice for which to fetch the outgoing payments */ suspend fun getInvoiceForPaymentHash( - paymentHash: String + paymentHash: String, ): Invoice { requireValidAuth() return executeQuery( @@ -1122,7 +1167,7 @@ class LightsparkCoroutinesClient private constructor( val paymentsJson = requireNotNull(outputJson.jsonObject["payments"]) { "No payments found in response" } serializerFormat.decodeFromJsonElement(paymentsJson) - } + }, ) } diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkSyncClient.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkSyncClient.kt index bc80e00e..481cdecb 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkSyncClient.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/LightsparkSyncClient.kt @@ -200,6 +200,8 @@ class LightsparkSyncClient constructor(config: ClientConfig) { * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. * @param amountMsats The amount to pay in milli-satoshis. Defaults to the full amount of the invoice. * @param timeoutSecs The number of seconds to wait for the payment to complete. Defaults to 60. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return The payment details. */ @JvmOverloads @@ -209,8 +211,18 @@ class LightsparkSyncClient constructor(config: ClientConfig) { maxFeesMsats: Long, amountMsats: Long? = null, timeoutSecs: Int = 60, + idempotencyKey: String? = null, ): OutgoingPayment = - runBlocking { asyncClient.payInvoice(nodeId, encodedInvoice, maxFeesMsats, amountMsats, timeoutSecs) } + runBlocking { + asyncClient.payInvoice( + nodeId, + encodedInvoice, + maxFeesMsats, + amountMsats, + timeoutSecs, + idempotencyKey, + ) + } /** * [payUmaInvoice] sends an UMA payment to a node on the Lightning Network, based on the invoice (as defined by the @@ -227,6 +239,8 @@ class LightsparkSyncClient constructor(config: ClientConfig) { * @param signingPrivateKey The sender's signing private key. Used to hash the sender identifier. * @param senderIdentifier Optional identifier of the sender. If provided, this will be hashed using a * monthly-rotated seed and used for anonymized analysis. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return The payment details. */ @JvmOverloads @@ -239,6 +253,7 @@ class LightsparkSyncClient constructor(config: ClientConfig) { timeoutSecs: Int = 60, signingPrivateKey: ByteArray? = null, senderIdentifier: String? = null, + idempotencyKey: String? = null, ): OutgoingPayment = runBlocking { asyncClient.payUmaInvoice( @@ -249,6 +264,7 @@ class LightsparkSyncClient constructor(config: ClientConfig) { timeoutSecs, signingPrivateKey, senderIdentifier, + idempotencyKey, ) } @@ -416,14 +432,19 @@ class LightsparkSyncClient constructor(config: ClientConfig) { * @param amountSats The amount of funds to withdraw in SATOSHI. * @param bitcoinAddress The Bitcoin address to withdraw funds to. * @param mode The mode to use for the withdrawal. See `WithdrawalMode` for more information. + * @param idempotencyKey An optional key to ensure idempotency of the withdrawal. If provided, the same result will + * be returned for the same idempotency key without triggering a new withdrawal. */ + @JvmOverloads @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class) fun requestWithdrawal( nodeId: String, amountSats: Long, bitcoinAddress: String, mode: WithdrawalMode, - ): WithdrawalRequest = runBlocking { asyncClient.requestWithdrawal(nodeId, amountSats, bitcoinAddress, mode) } + idempotencyKey: String? = null, + ): WithdrawalRequest = + runBlocking { asyncClient.requestWithdrawal(nodeId, amountSats, bitcoinAddress, mode, idempotencyKey) } /** * Sends a payment directly to a node on the Lightning Network through the public key of the node without an invoice. @@ -435,6 +456,8 @@ class LightsparkSyncClient constructor(config: ClientConfig) { * As guidance, a maximum fee of 15 basis points should make almost all transactions succeed. For example, * for a transaction between 10k sats and 100k sats, this would mean a fee limit of 15 to 150 sats. * @param timeoutSecs The timeout in seconds that we will try to make the payment. + * @param idempotencyKey An optional key to ensure idempotency of the payment. If provided, the same result will be + * returned for the same idempotency key without triggering a new payment. * @return An `OutgoingPayment` object if the payment was successful, or throws if the payment failed. * @throws LightsparkException if the payment failed. */ @@ -445,6 +468,7 @@ class LightsparkSyncClient constructor(config: ClientConfig) { amountMsats: Long, maxFeesMsats: Long, timeoutSecs: Int = 60, + idempotencyKey: String? = null, ): OutgoingPayment = runBlocking { asyncClient.sendPayment( payerNodeId, @@ -452,6 +476,7 @@ class LightsparkSyncClient constructor(config: ClientConfig) { amountMsats, maxFeesMsats, timeoutSecs, + idempotencyKey, ) } @@ -579,29 +604,41 @@ class LightsparkSyncClient constructor(config: ClientConfig) { /** * fetch outgoing payments for a given payment hash - * + * * @param paymentHash the payment hash of the invoice for which to fetch the outgoing payments * @param transactionStatuses the transaction statuses to filter the payments by. If null, all payments will be returned. */ @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class) fun getOutgoingPaymentsForPaymentHash( paymentHash: String, - transactionStatuses: List? = null + transactionStatuses: List? = null, ): List = runBlocking { asyncClient.getOutgoingPaymentForPaymentHash(paymentHash, transactionStatuses) } + /** + * Fetch outgoing payment for a given idempotency key + * + * @param idempotencyKey The idempotency key used when creating the payment. + */ + @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class) + fun getOutgoingPaymentForIdempotencyKey( + idempotencyKey: String, + ): OutgoingPayment? = runBlocking { + asyncClient.getOutgoingPaymentForIdempotencyKey(idempotencyKey) + } + @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class) fun getIncomingPaymentsForInvoice( invoiceId: String, - transactionStatuses: List? = null + transactionStatuses: List? = null, ): List = runBlocking { asyncClient.getIncomingPaymentsForInvoice(invoiceId, transactionStatuses) } @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class) fun getInvoiceForPaymentHash( - paymentHash: String + paymentHash: String, ): Invoice = runBlocking { asyncClient.getInvoiceForPaymentHash(paymentHash) } @@ -627,7 +664,12 @@ class LightsparkSyncClient constructor(config: ClientConfig) { * @param inviterRegionCode The region of the inviter. * @return The invitation that was created. */ - @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class, IllegalArgumentException::class) + @Throws( + LightsparkException::class, + LightsparkAuthenticationException::class, + CancellationException::class, + IllegalArgumentException::class, + ) fun createUmaInvitationWithIncentives( inviterUma: String, inviterPhoneNumberE164: String, @@ -659,7 +701,12 @@ class LightsparkSyncClient constructor(config: ClientConfig) { * @param inviteeRegionCode The region of the invitee. * @returns The invitation that was claimed. */ - @Throws(LightsparkException::class, LightsparkAuthenticationException::class, CancellationException::class, IllegalArgumentException::class) + @Throws( + LightsparkException::class, + LightsparkAuthenticationException::class, + CancellationException::class, + IllegalArgumentException::class, + ) fun claimUmaInvitationWithIncentives( invitationCode: String, inviteeUma: String, diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/OutgoingPaymentForIdempotencyKey.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/OutgoingPaymentForIdempotencyKey.kt new file mode 100644 index 00000000..13e68ccd --- /dev/null +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/OutgoingPaymentForIdempotencyKey.kt @@ -0,0 +1,20 @@ +package com.lightspark.sdk.graphql + +import com.lightspark.sdk.model.OutgoingPayment + +const val OutgoingPaymentForIdempotencyKeyQuery = """ +query OutgoingPaymentForIdempotencyKey( + ${'$'}idempotency_key: String! +) { + outgoing_payment_for_idempotency_key(input: { + idempotency_key: ${'$'}idempotency_key, + statuses: ${'$'}transactionStatuses + }) { + payment { + ...OutgoingPaymentFragment + } + } +} + + ${OutgoingPayment.FRAGMENT} +""" diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayInvoice.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayInvoice.kt index 3a45eccc..91e752c2 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayInvoice.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayInvoice.kt @@ -9,6 +9,7 @@ const val PayInvoiceMutation = """ ${'$'}timeout_secs: Int! ${'$'}maximum_fees_msats: Long! ${'$'}amount_msats: Long + ${'$'}idempotency_key: String ) { pay_invoice( input: { @@ -17,6 +18,7 @@ const val PayInvoiceMutation = """ timeout_secs: ${'$'}timeout_secs amount_msats: ${'$'}amount_msats maximum_fees_msats: ${'$'}maximum_fees_msats + idempotency_key: ${'$'}idempotency_key } ) { payment { diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt index 22ae6948..3078bee9 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/PayUmaInvoice.kt @@ -10,6 +10,7 @@ const val PayUmaInvoiceMutation = """ ${'$'}maximum_fees_msats: Long! ${'$'}amount_msats: Long ${'$'}sender_hash: String + ${'$'}idempotency_key: String ) { pay_uma_invoice( input: { @@ -19,6 +20,7 @@ const val PayUmaInvoiceMutation = """ amount_msats: ${'$'}amount_msats maximum_fees_msats: ${'$'}maximum_fees_msats sender_hash: ${'$'}sender_hash + idempotency_key: ${'$'}idempotency_key } ) { payment { diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/RequestWithdrawal.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/RequestWithdrawal.kt index a90c1ece..24ddcd8f 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/RequestWithdrawal.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/RequestWithdrawal.kt @@ -8,12 +8,14 @@ const val RequestWithdrawalMutation = """ ${'$'}amount_sats: Long! ${'$'}bitcoin_address: String! ${'$'}withdrawal_mode: WithdrawalMode! + ${'$'}idempotency_key: String ) { request_withdrawal(input: { node_id: ${'$'}node_id amount_sats: ${'$'}amount_sats bitcoin_address: ${'$'}bitcoin_address withdrawal_mode: ${'$'}withdrawal_mode + idempotency_key: ${'$'}idempotency_key }) { request { ...WithdrawalRequestFragment diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/SendPayment.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/SendPayment.kt index 07a732aa..0211e9bd 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/SendPayment.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/graphql/SendPayment.kt @@ -9,6 +9,7 @@ const val SendPaymentMutation = """ ${'$'}timeout_secs: Int! ${'$'}amount_msats: Long! ${'$'}maximum_fees_msats: Long! + ${'$'}idempotency_key: String ) { send_payment( input: { @@ -17,6 +18,7 @@ const val SendPaymentMutation = """ timeout_secs: ${'$'}timeout_secs amount_msats: ${'$'}amount_msats maximum_fees_msats: ${'$'}maximum_fees_msats + idempotency_key: ${'$'}idempotency_key } ) { payment { diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CreateUmaInvoiceInput.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CreateUmaInvoiceInput.kt index 2b131666..9f487dcb 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CreateUmaInvoiceInput.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CreateUmaInvoiceInput.kt @@ -8,6 +8,11 @@ import kotlinx.serialization.Serializable /** * + * @param nodeId The node from which to create the invoice. + * @param amountMsats The amount for which the invoice should be created, in millisatoshis. + * @param metadataHash The SHA256 hash of the UMA metadata payload. This will be present in the h-tag (SHA256 purpose of payment) of the resulting Bolt 11 invoice. + * @param expirySecs The expiry of the invoice in seconds. Default value is 86400 (1 day). + * @param receiverHash An optional, monthly-rotated, unique hashed identifier corresponding to the receiver of the payment. */ @Serializable @SerialName("CreateUmaInvoiceInput") diff --git a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CurrencyUnit.kt b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CurrencyUnit.kt index dd5aa5d9..84a55fe8 100644 --- a/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CurrencyUnit.kt +++ b/lightspark-sdk/src/commonMain/kotlin/com/lightspark/sdk/model/CurrencyUnit.kt @@ -23,6 +23,9 @@ enum class CurrencyUnit( /** United States Dollar. **/ USD("USD"), + /** Mexican Peso. **/ + MXN("MXN"), + /** 0.000000001 (10e-9) Bitcoin or a billionth of a Bitcoin. We recommend using the Satoshi unit instead when possible. **/ NANOBITCOIN("NANOBITCOIN"), diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt b/umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt similarity index 66% rename from umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt rename to umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt index 61fd37c9..48db747c 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp2.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/ReceivingVasp.kt @@ -4,7 +4,18 @@ import com.lightspark.sdk.ClientConfig import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.auth.AccountApiTokenAuthProvider import com.lightspark.sdk.model.Node +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call import io.ktor.server.plugins.origin @@ -31,14 +42,21 @@ import me.uma.UmaInvoiceCreator import me.uma.UmaProtocolHelper import me.uma.UnsupportedVersionException import me.uma.protocol.CounterPartyDataOptions +import me.uma.protocol.InvoiceCurrency import me.uma.protocol.KycStatus import me.uma.protocol.LnurlpResponse import me.uma.protocol.PayRequest import me.uma.protocol.createCounterPartyDataOptions import me.uma.protocol.createPayeeData import me.uma.protocol.identifier +import java.util.UUID +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.plus +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive -class Vasp2( + +class ReceivingVasp( private val config: UmaConfig, private val uma: UmaProtocolHelper, private val lightsparkClient: LightsparkCoroutinesClient, @@ -46,6 +64,140 @@ class Vasp2( private val nonceCache = InMemoryNonceCache(Clock.System.now().epochSeconds) private val coroutineScope = CoroutineScope(Dispatchers.IO) private lateinit var senderUmaVersion: String + private val httpClient = HttpClient { + install(ContentNegotiation) { + json( + Json { + isLenient = true + }, + ) + } + } + + suspend fun createInvoice(call: ApplicationCall): String { + val (status, data) = createUmaInvoice(call) + if (status != HttpStatusCode.OK) { + call.respond(status, data) + return data + } else { + call.respond(data) + } + return "OK" + } + + suspend fun createAndSendInvoice(call: ApplicationCall): String { + val senderUma = call.parameters["senderUma"] ?: run { + call.respond(HttpStatusCode.BadRequest, "SenderUma not provided.") + return "SenderUma not provided." + } + val senderUmaComponents = senderUma.split("@") + if (senderUmaComponents.size != 2) { + call.respond(HttpStatusCode.BadRequest, "SenderUma format invalid: $senderUma.") + return "SenderUma format invalid: $senderUma." + } + val (status, data) = createUmaInvoice(call, senderUma) + if (status != HttpStatusCode.OK) { + call.respond(status, data) + return data + } + val senderComponents = senderUma.split("@") + val sendingVaspDomain = senderComponents.getOrNull(1) ?: run { + call.respond(HttpStatusCode.BadRequest, "Invalid senderUma.") + return "Invalid senderUma." + } + val wellKnownConfiguration = "http://$sendingVaspDomain/.well-known/uma-configuration" + val umaEndpoint = try { + val umaConfigResponse = httpClient.get(wellKnownConfiguration) + if (umaConfigResponse.status != HttpStatusCode.OK) { + call.respond( + HttpStatusCode.FailedDependency, + "failed to fetch request / pay endpoint at $wellKnownConfiguration", + ) + return "failed to fetch request / pay endpoint at $wellKnownConfiguration" + } + Json.decodeFromString( + umaConfigResponse.bodyAsText(), + )["uma_request_endpoint"]?.jsonPrimitive?.content + } catch (e: Exception) { + call.respond( + HttpStatusCode.FailedDependency, + "failed to fetch request / pay endpoint at $wellKnownConfiguration", + ) + return "failed to fetch request / pay endpoint at $wellKnownConfiguration" + } + if (umaEndpoint == null) { + call.respond(HttpStatusCode.FailedDependency, "failed to fetch $wellKnownConfiguration") + return "failed to fetch $wellKnownConfiguration" + } + val response = try { + httpClient.post(umaEndpoint) { + contentType(ContentType.Application.Json) + setBody(parameter("invoice", data)) + } + } catch (e: Exception) { + call.respond(HttpStatusCode.FailedDependency, "failed to fetch $umaEndpoint") + return "failed to fetch $umaEndpoint" + } + if (response.status != HttpStatusCode.OK) { + call.respond(HttpStatusCode.InternalServerError, "Payreq to Sending Vasp failed: ${response.status}") + return "Payreq to sending failed: ${response.status}" + } + call.respond(response.body()) + return "OK" + } + + private fun createUmaInvoice( + call: ApplicationCall, senderUma: String? = null + ): Pair { + val amount = try { + call.parameters["amount"]?.toLong() ?: run { + return HttpStatusCode.BadRequest to "Amount not provided." + } + } catch (e: NumberFormatException) { + return HttpStatusCode.BadRequest to "Amount not parsable as number." + } + + val currency = call.parameters["currencyCode"]?.let { currencyCode -> + // check if we support this currency code. + getReceivingCurrencies(UMA_VERSION_STRING).firstOrNull { + it.code == currencyCode + } ?: run { + return HttpStatusCode.BadRequest to "Unsupported CurrencyCode $currencyCode." + } + } ?: run { + return HttpStatusCode.BadRequest to "CurrencyCode not provided." + } + + if (amount < currency.minSendable() || amount > currency.maxSendable()) { + return HttpStatusCode.BadRequest to "CurrencyCode amount is outside of sendable range." + } + + val expiresIn2Days = Clock.System.now().plus(2, DateTimeUnit.HOUR*24) + + val receiverUma = buildReceiverUma(call) + + val invoice = uma.getInvoice( + receiverUma = receiverUma, + invoiceUUID = UUID.randomUUID().toString(), + amount = amount, + receivingCurrency = InvoiceCurrency( + currency.code, currency.name, currency.symbol, currency.decimals + ), + expiration = expiresIn2Days.epochSeconds, + isSubjectToTravelRule = true, + requiredPayerData = createCounterPartyDataOptions( + "name" to false, + "email" to false, + "compliance" to true, + "identifier" to true, + ), + callback = getLnurlpCallback(call), // structured the same, going to /api/uma/payreq/{user_id} + privateSigningKey = config.umaSigningPrivKey, + senderUma = senderUma + ) + + return HttpStatusCode.OK to invoice.toBech32() + } suspend fun handleLnurlp(call: ApplicationCall): String { val username = call.parameters["username"] @@ -155,7 +307,7 @@ class Vasp2( } val lnurlInvoiceCreator = object : UmaInvoiceCreator { - override fun createUmaInvoice(amountMsats: Long, metadata: String): CompletableFuture { + override fun createUmaInvoice(amountMsats: Long, metadata: String, receiverIdentifier: String?,): CompletableFuture { return coroutineScope.future { lightsparkClient.createLnurlInvoice(config.nodeID, amountMsats, metadata).data.encodedPaymentRequest } @@ -228,6 +380,7 @@ class Vasp2( return "Invalid payreq signature." } + senderUmaVersion = UMA_VERSION_STRING val receivingCurrency = getReceivingCurrencies(senderUmaVersion) .firstOrNull { it.code == request.receivingCurrencyCode() } ?: run { call.respond(HttpStatusCode.BadRequest, "Unsupported currency.") @@ -331,20 +484,30 @@ class Vasp2( return "$protocol://$host$port$path" } + private fun buildReceiverUma(call: ApplicationCall) = "$${config.username}@${getReceivingVaspDomain(call)}" + private fun getReceivingVaspDomain(call: ApplicationCall) = config.vaspDomain ?: call.originWithPort() } -fun Routing.registerVasp2Routes(vasp2: Vasp2) { +fun Routing.registerReceivingVaspRoutes(receivingVasp: ReceivingVasp) { get("/.well-known/lnurlp/{username}") { - call.debugLog(vasp2.handleLnurlp(call)) + call.debugLog(receivingVasp.handleLnurlp(call)) } - get("/api/uma/payreq/{uuid}") { - call.debugLog(vasp2.handleLnurlPayreq(call)) + get("/api/lnurl/payreq/{uuid}") { + call.debugLog(receivingVasp.handleLnurlPayreq(call)) } post("/api/uma/payreq/{uuid}") { - call.debugLog(vasp2.handleUmaPayreq(call)) + call.debugLog(receivingVasp.handleUmaPayreq(call)) + } + + post("/api/uma/create_invoice") { + call.debugLog(receivingVasp.createInvoice(call)); + } + + post("/api/uma/create_and_send_invoice") { + call.debugLog(receivingVasp.createAndSendInvoice(call)) } } diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt b/umaserverdemo/src/main/kotlin/com/lightspark/SendingVasp.kt similarity index 72% rename from umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt rename to umaserverdemo/src/main/kotlin/com/lightspark/SendingVasp.kt index 9f9fecb9..36ae8448 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/SendingVasp.kt @@ -50,6 +50,7 @@ import me.uma.UMA_VERSION_STRING import me.uma.UmaProtocolHelper import me.uma.protocol.CounterPartyDataOptions import me.uma.protocol.CurrencySerializer +import me.uma.protocol.Invoice import me.uma.protocol.KycStatus import me.uma.protocol.PayRequest import me.uma.protocol.UtxoWithAmount @@ -57,7 +58,7 @@ import me.uma.protocol.createPayerData import me.uma.selectHighestSupportedVersion import me.uma.utils.serialFormat -class Vasp1( +class SendingVasp( private val config: UmaConfig, private val uma: UmaProtocolHelper, private val lightsparkClient: LightsparkCoroutinesClient, @@ -71,10 +72,172 @@ class Vasp1( ) } } - private val requestDataCache = Vasp1RequestCache() + private val requestDataCache = SendingVaspRequestCache() private val nonceCache = InMemoryNonceCache(Clock.System.now().epochSeconds) private lateinit var receiverUmaVersion: String + suspend fun payInvoice(call: ApplicationCall): String { + val umaInvoice = call.request.queryParameters["invoice"]?.let { invoiceStr -> + // handle the case where users have provided the uuid of a cached invoice, rather + // than a full bech32 encoded invoice + if (!invoiceStr.startsWith("uma")) { + requestDataCache.getUmaInvoiceData(invoiceStr) + } else { + Invoice.fromBech32(invoiceStr) + } + } ?: run { + call.respond(HttpStatusCode.BadRequest, "Unable to decode invoice.") + return "Unable to decode invoice." + } + val receiverVaspDomain = umaInvoice.receiverUma.split("@").getOrNull(1) ?: run { + call.respond(HttpStatusCode.FailedDependency, "Failed to parse receiver vasp.") + return "Failed to parse receiver vasp." + } + val receiverVaspPubKeys = try { + uma.fetchPublicKeysForVasp(receiverVaspDomain) + } catch (e: Exception) { + call.application.environment.log.error("Failed to fetch pubkeys", e) + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") + return "Failed to fetch public keys." + } + if (!uma.verifyUmaInvoice(umaInvoice, receiverVaspPubKeys)) { + call.respond(HttpStatusCode.BadRequest, "Unable to decode invoice.") + return "Unable to decode invoice." + } + if (umaInvoice.expiration < Clock.System.now().epochSeconds) { + call.respond(HttpStatusCode.BadRequest, "Invoice ${umaInvoice.invoiceUUID} has expired.") + return "Invoice ${umaInvoice.invoiceUUID} has expired." + } + + val payer = getPayerProfile(umaInvoice.requiredPayerData ?: emptyMap(), call) + // initial request data is cached in request and pay invoice + + val currencyValid = getReceivingCurrencies(UMA_VERSION_STRING).any { + it.code == umaInvoice.receivingCurrency.code + } + if (!currencyValid) { + call.respond(HttpStatusCode.BadRequest, "Receiving currency code not supported.") + return "Receiving currency code not supported." + } + + val trInfo = "Here is some fake travel rule info. It's up to you to actually implement this if needed." + val payerUtxos = emptyList() + + val payReq = uma.getPayRequest( + receiverEncryptionPubKey = receiverVaspPubKeys.getEncryptionPublicKey(), + sendingVaspPrivateKey = config.umaSigningPrivKey, + receivingCurrencyCode = umaInvoice.receivingCurrency.code, + isAmountInReceivingCurrency = umaInvoice.receivingCurrency.code != "SAT", + amount = umaInvoice.amount, + payerIdentifier = payer.identifier, + payerKycStatus = KycStatus.VERIFIED, + payerNodePubKey = getNodePubKey(), + utxoCallback = getUtxoCallback(call, "1234abc"), + travelRuleInfo = trInfo, + payerUtxos = payerUtxos, + payerName = payer.name, + payerEmail = payer.email, + comment = call.request.queryParameters["comment"], + receiverUmaVersion = umaInvoice.umaVersion, + ) + + val response = try { + httpClient.post(umaInvoice.callback) { + contentType(ContentType.Application.Json) + setBody(payReq.toJson()) + } + } catch (e: Exception) { + call.respond(HttpStatusCode.FailedDependency, "Unable to connect to ${umaInvoice.callback}") + return "Unable to connect to ${umaInvoice.callback}" + } + if (response.status != HttpStatusCode.OK) { + call.respond( + HttpStatusCode.InternalServerError, + "Payreq to receiving vasp failed: ${response.status}" + ) + return "Payreq to receiving vasp failed: ${response.status}" + } + + val payReqResponse = try { + uma.parseAsPayReqResponse(response.body()) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Failed to parse payreq response.") + return "Failed to parse payreq response." + } + + if (!payReqResponse.isUmaResponse()) { + call.application.environment.log.error("Got a non-UMA response: ${payReqResponse.toJson()}") + call.respond( + HttpStatusCode.FailedDependency, + "Received non-UMA response from receiving vasp for an UMA request" + ) + return "Received non-UMA response from receiving vasp." + } + + try { + uma.verifyPayReqResponseSignature(payReqResponse, receiverVaspPubKeys, payer.identifier, nonceCache) + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, "Failed to verify lnurlp response signature.") + return "Failed to verify lnurlp response signature." + } + + val invoice = try { + lightsparkClient.decodeInvoice(payReqResponse.encodedInvoice) + } catch (e: Exception) { + call.application.environment.log.error("Failed to decode invoice", e) + call.respond(HttpStatusCode.InternalServerError, "Failed to decode invoice.") + return "Failed to decode invoice." + } + + val newCallbackId = requestDataCache.savePayReqData( + encodedInvoice = payReqResponse.encodedInvoice, + utxoCallback = getUtxoCallback(call, "1234abc"), + invoiceData = invoice, + ) + + // we've successfully fulfilled this request, so remove cached invoice uuid data + requestDataCache.removeUmaInvoiceData(umaInvoice.invoiceUUID) + + call.respond( + buildJsonObject { + put("encodedInvoice", payReqResponse.encodedInvoice) + put("callbackUuid", newCallbackId) + put("amountMsats", invoice.amount.toMilliSats()) + put("amountReceivingCurrency", payReqResponse.paymentInfo?.amount ?: umaInvoice.amount) + put("receivingCurrencyDecimals", payReqResponse.paymentInfo?.decimals ?: 0) + put("exchangeFeesMsats", payReqResponse.paymentInfo?.exchangeFeesMillisatoshi ?: 0) + put("conversionRate", payReqResponse.paymentInfo?.multiplier ?: 1000) + put("receivingCurrencyCode", payReqResponse.paymentInfo?.currencyCode ?: "SAT") + }, + ) + + return "OK" + } + + suspend fun requestInvoicePayment(call: ApplicationCall): String { + val umaInvoice = call.parameters["invoice"]?.let(Invoice::fromBech32) ?: run { + call.respond(HttpStatusCode.BadRequest, "Unable to decode invoice.") + return "Unable to decode invoice." + } + val receiverVaspDomain = umaInvoice.receiverUma.split("@").getOrNull(1) ?: run { + call.respond(HttpStatusCode.FailedDependency, "Failed to parse receiver vasp.") + return "Failed to parse receiver vasp." + } + val receiverVaspPubKeys = try { + uma.fetchPublicKeysForVasp(receiverVaspDomain) + } catch (e: Exception) { + call.application.environment.log.error("Failed to fetch pubkeys", e) + call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") + return "Failed to fetch public keys." + } + if (!uma.verifyUmaInvoice(umaInvoice, receiverVaspPubKeys)) { + call.respond(HttpStatusCode.BadRequest, "Invalid invoice signature.") + return "Unable to decode invoice." + } + requestDataCache.saveUmaInvoice(umaInvoice.invoiceUUID, umaInvoice) + return "OK" + } + suspend fun handleClientUmaLookup(call: ApplicationCall): String { val receiverAddress = call.parameters["receiver"] if (receiverAddress == null) { @@ -225,9 +388,9 @@ class Vasp1( // The default for UMA requests should be to assume the receiving currency, but for non-UMA, we default to msats. val isAmountInMsats = call.request.queryParameters["isAmountInMsats"]?.toBoolean() ?: !isUma - val vasp2PubKeys = if (isUma) { + val receiverVaspPubKeys = if (isUma) { try { - uma.fetchPublicKeysForVasp(initialRequestData.vasp2Domain) + uma.fetchPublicKeysForVasp(initialRequestData.receivingVaspDomain) } catch (e: Exception) { call.application.environment.log.error("Failed to fetch pubkeys", e) call.respond(HttpStatusCode.FailedDependency, "Failed to fetch public keys.") @@ -244,7 +407,7 @@ class Vasp1( val payReq = try { if (isUma) { uma.getPayRequest( - receiverEncryptionPubKey = vasp2PubKeys!!.getEncryptionPublicKey(), + receiverEncryptionPubKey = receiverVaspPubKeys!!.getEncryptionPublicKey(), sendingVaspPrivateKey = config.umaSigningPrivKey, receivingCurrencyCode = currencyCode, isAmountInReceivingCurrency = !isAmountInMsats, @@ -311,7 +474,7 @@ class Vasp1( if (isUma) { try { - uma.verifyPayReqResponseSignature(payReqResponse, vasp2PubKeys!!, payer.identifier, nonceCache) + uma.verifyPayReqResponseSignature(payReqResponse, receiverVaspPubKeys!!, payer.identifier, nonceCache) } catch (e: Exception) { call.respond(HttpStatusCode.BadRequest, "Failed to verify lnurlp response signature.") return "Failed to verify lnurlp response signature." @@ -421,7 +584,7 @@ class Vasp1( private suspend fun sendPostTransactionCallback( payment: OutgoingPayment, - payReqData: Vasp1PayReqData, + payReqData: SendingVaspPayReqData, call: ApplicationCall, ) { val utxos = payment.umaPostTransactionData?.map { @@ -500,17 +663,25 @@ class Vasp1( } } -fun Routing.registerVasp1Routes(vasp1: Vasp1) { +fun Routing.registerSendingVaspRoutes(sendingVasp: SendingVasp) { get("/api/umalookup/{receiver}") { - call.debugLog(vasp1.handleClientUmaLookup(call)) + call.debugLog(sendingVasp.handleClientUmaLookup(call)) } get("/api/umapayreq/{callbackUuid}") { - call.debugLog(vasp1.handleClientUmaPayReq(call)) + call.debugLog(sendingVasp.handleClientUmaPayReq(call)) } post("/api/sendpayment/{callbackUuid}") { - call.debugLog(vasp1.handleClientSendPayment(call)) + call.debugLog(sendingVasp.handleClientSendPayment(call)) + } + + post("/api/uma/pay_invoice") { + call.debugLog(sendingVasp.payInvoice(call)) + } + + post("/api/uma/request_invoice_payment") { + call.debugLog(sendingVasp.requestInvoicePayment(call)) } } diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt b/umaserverdemo/src/main/kotlin/com/lightspark/SendingVaspRequestCache.kt similarity index 62% rename from umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt rename to umaserverdemo/src/main/kotlin/com/lightspark/SendingVaspRequestCache.kt index d63584a4..90a6ffe9 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/Vasp1RequestCache.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/SendingVaspRequestCache.kt @@ -1,6 +1,7 @@ package com.lightspark import com.lightspark.sdk.model.InvoiceData +import me.uma.protocol.Invoice import java.util.UUID import me.uma.protocol.LnurlpResponse @@ -8,42 +9,52 @@ import me.uma.protocol.LnurlpResponse * A simple in-memory cache for data that needs to be remembered between calls to VASP1. In practice, this would be * stored in a database or other persistent storage. */ -class Vasp1RequestCache { +class SendingVaspRequestCache { /** * This is a map of the UMA request UUID to the LnurlpResponse from that initial Lnurlp request. * This is used to cache the LnurlpResponse so that we can use it to generate the UMA payreq without the client * having to make another Lnurlp request or remember lots of details. * NOTE: In production, this should be stored in a database or other persistent storage. */ - private val lnurlpRequestCache: MutableMap = mutableMapOf() + private val lnurlpRequestCache: MutableMap = mutableMapOf() /** * This is a map of the UMA request UUID to the payreq data that we generated for that request. * This is used to cache the payreq data so that we can pay the invoice when the user confirms * NOTE: In production, this should be stored in a database or other persistent storage. */ - private val payReqCache: MutableMap = mutableMapOf() + private val payReqCache: MutableMap = mutableMapOf() - fun getLnurlpResponseData(uuid: String): Vasp1InitialRequestData? { + private val umaInvoiceCache: MutableMap = mutableMapOf() + + fun getLnurlpResponseData(uuid: String): SendingVaspInitialRequestData? { return lnurlpRequestCache[uuid] } - fun getPayReqData(uuid: String): Vasp1PayReqData? { + fun getPayReqData(uuid: String): SendingVaspPayReqData? { return payReqCache[uuid] } + fun getUmaInvoiceData(uuid: String): Invoice? { + return umaInvoiceCache[uuid] + } + fun saveLnurlpResponseData(lnurlpResponse: LnurlpResponse, receiverId: String, vasp2Domain: String): String { val uuid = UUID.randomUUID().toString() - lnurlpRequestCache[uuid] = Vasp1InitialRequestData(lnurlpResponse, receiverId, vasp2Domain) + lnurlpRequestCache[uuid] = SendingVaspInitialRequestData(lnurlpResponse, receiverId, vasp2Domain) return uuid } fun savePayReqData(encodedInvoice: String, utxoCallback: String, invoiceData: InvoiceData): String { val uuid = UUID.randomUUID().toString() - payReqCache[uuid] = Vasp1PayReqData(encodedInvoice, utxoCallback, invoiceData) + payReqCache[uuid] = SendingVaspPayReqData(encodedInvoice, utxoCallback, invoiceData) return uuid } + fun saveUmaInvoice(uuid: String, invoice: Invoice) { + umaInvoiceCache[uuid] = invoice + } + fun removeLnurlpResponseData(uuid: String) { lnurlpRequestCache.remove(uuid) } @@ -51,15 +62,19 @@ class Vasp1RequestCache { fun removePayReqData(uuid: String) { payReqCache.remove(uuid) } + + fun removeUmaInvoiceData(uuid: String) { + umaInvoiceCache.remove(uuid) + } } -data class Vasp1InitialRequestData( +data class SendingVaspInitialRequestData( val lnurlpResponse: LnurlpResponse, val receiverId: String, - val vasp2Domain: String, + val receivingVaspDomain: String, ) -data class Vasp1PayReqData( +data class SendingVaspPayReqData( val encodedInvoice: String, val utxoCallback: String, val invoiceData: InvoiceData, diff --git a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt index 0d2e40e1..e03e98ad 100644 --- a/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt +++ b/umaserverdemo/src/main/kotlin/com/lightspark/plugins/Routing.kt @@ -1,30 +1,33 @@ package com.lightspark.plugins import com.lightspark.UmaConfig -import com.lightspark.Vasp1 -import com.lightspark.Vasp2 +import com.lightspark.SendingVasp +import com.lightspark.ReceivingVasp import com.lightspark.debugLog import com.lightspark.handlePubKeyRequest -import com.lightspark.registerVasp1Routes -import com.lightspark.registerVasp2Routes +import com.lightspark.isDomainLocalhost +import com.lightspark.originWithPort +import com.lightspark.registerSendingVaspRoutes +import com.lightspark.registerReceivingVaspRoutes import com.lightspark.sdk.ClientConfig import com.lightspark.sdk.LightsparkCoroutinesClient import com.lightspark.sdk.auth.AccountApiTokenAuthProvider import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.call -import io.ktor.server.request.ContentTransformationException -import io.ktor.server.request.receive import io.ktor.server.request.receiveText import io.ktor.server.response.respond import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing import kotlinx.datetime.Clock -import kotlinx.serialization.json.JsonObject import me.uma.InMemoryNonceCache import me.uma.InMemoryPublicKeyCache import me.uma.UmaProtocolHelper +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put fun Application.configureRouting( config: UmaConfig, @@ -37,17 +40,35 @@ fun Application.configureRouting( authProvider = AccountApiTokenAuthProvider(config.apiClientID, config.apiClientSecret), ), ) - val vasp1 = Vasp1(config, uma, client) - val vasp2 = Vasp2(config, uma, client) + val sendingVasp = SendingVasp(config, uma, client) + val receivingVasp = ReceivingVasp(config, uma, client) routing { - registerVasp1Routes(vasp1) - registerVasp2Routes(vasp2) + registerSendingVaspRoutes(sendingVasp) + registerReceivingVaspRoutes(receivingVasp) get("/.well-known/lnurlpubkey") { call.debugLog(handlePubKeyRequest(call, config)) } + get("/.well-known/uma-configuration") { + val domain = config.vaspDomain ?: call.originWithPort() + val scheme = if (isDomainLocalhost(domain)) "http" else "https" + call.respond( + HttpStatusCode.OK, + buildJsonObject { + put("uma_request_endpoint", "$scheme://$domain/api/uma/request_invoice_payment") + put( + "uma_major_versions", + buildJsonArray { + add(0) + add(1) + }, + ) + }, + ) + } + post("/api/uma/utxoCallback") { val postTransactionCallback = try { uma.parseAsPostTransactionCallback(call.receiveText()) diff --git a/wallet-sdk/src/commonMain/kotlin/com/lightspark/sdk/wallet/model/CurrencyUnit.kt b/wallet-sdk/src/commonMain/kotlin/com/lightspark/sdk/wallet/model/CurrencyUnit.kt index 804cba24..859ec663 100644 --- a/wallet-sdk/src/commonMain/kotlin/com/lightspark/sdk/wallet/model/CurrencyUnit.kt +++ b/wallet-sdk/src/commonMain/kotlin/com/lightspark/sdk/wallet/model/CurrencyUnit.kt @@ -23,6 +23,9 @@ enum class CurrencyUnit( /** United States Dollar. **/ USD("USD"), + /** Mexican Peso. **/ + MXN("MXN"), + /** 0.000000001 (10e-9) Bitcoin or a billionth of a Bitcoin. We recommend using the Satoshi unit instead when possible. **/ NANOBITCOIN("NANOBITCOIN"), From 2a67dec8b582b6ffac06e8f2d2f39ac02976fd4f Mon Sep 17 00:00:00 2001 From: runner Date: Thu, 13 Feb 2025 21:01:09 +0000 Subject: [PATCH 4/4] Bump lightspark-sdk to version 0.19.1 --- lightspark-sdk/README.md | 4 ++-- lightspark-sdk/gradle.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lightspark-sdk/README.md b/lightspark-sdk/README.md index e2d84790..14577555 100644 --- a/lightspark-sdk/README.md +++ b/lightspark-sdk/README.md @@ -17,14 +17,14 @@ Start by installing the SDK from maven: **build.gradle:** ```groovy dependencies { - implementation "com.lightspark:lightspark-sdk:0.19.0" + implementation "com.lightspark:lightspark-sdk:0.19.1" } ``` or with **build.gradle.kts:** ```kotlin dependencies { - implementation("com.lightspark:lightspark-sdk:0.19.0") + implementation("com.lightspark:lightspark-sdk:0.19.1") } ``` diff --git a/lightspark-sdk/gradle.properties b/lightspark-sdk/gradle.properties index 27033c36..399428d5 100644 --- a/lightspark-sdk/gradle.properties +++ b/lightspark-sdk/gradle.properties @@ -1,7 +1,7 @@ GROUP=com.lightspark POM_ARTIFACT_ID=lightspark-sdk # Don't bump this manually. Run `scripts/versions.main.kt ` to bump the version instead. -VERSION_NAME=0.19.0 +VERSION_NAME=0.19.1 POM_DESCRIPTION=The Lightspark API SDK for Kotlin and Java. POM_INCEPTION_YEAR=2023