From 1cf76e79c7244cea140318dec11c7347e5923bcd Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 12 Feb 2025 11:24:31 -0800 Subject: [PATCH] Expose idempotency keys for operations which allow them. --- .../lightspark/sdk/LightsparkFuturesClient.kt | 36 ++++++++++--- .../sdk/LightsparkCoroutinesClient.kt | 18 +++++++ .../lightspark/sdk/LightsparkSyncClient.kt | 51 ++++++++++++++++--- .../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 + 7 files changed, 99 insertions(+), 14 deletions(-) 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..f32a2ecb 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,27 @@ 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 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 +647,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..82b673f1 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, ) { @@ -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, ) { 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..f074de9d 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,14 +604,14 @@ 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) } @@ -594,14 +619,14 @@ class LightsparkSyncClient constructor(config: ClientConfig) { @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 +652,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 +689,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/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 {