import type { Logger } from "@lightsparkdev/core"; import { secp256k1 } from "@noble/curves/secp256k1"; import { bytesToNumberBE, hexToBytes, numberToBytesBE, } from "@noble/curves/utils"; import { sha256 } from "@noble/hashes/sha2"; import { SparkRequestError, SparkValidationError } from "../errors/types.js"; import type LightningReceiveRequest from "../graphql/objects/LightningReceiveRequest.js"; import { InitiatePreimageSwapRequest_Reason, type InitiatePreimageSwapResponse, type ProvidePreimageResponse, type QueryUserSignedRefundsResponse, SecretShare as SecretShareProto, type Transfer, type StartTransferRequest, type UserSignedRefund, } from "../proto/spark.js"; import { getSparkFrost } from "../spark-bindings/spark-bindings.js"; import { getTxFromRawTxBytes } from "../utils/bitcoin.js"; import { getCrypto } from "../utils/crypto.js"; import { optionsWithIdempotencyKey, type IdempotencyOptions, } from "../utils/idempotency.js"; import { LoggingService } from "../utils/logging-service.js"; import { decodeInvoice } from "./bolt11-spark.js"; import { type WalletConfigService } from "./config.js"; import { type ConnectionManager } from "./connection/connection.js"; import { type SigningService } from "./signing.js"; export type CreateLightningInvoiceParams = { invoiceCreator: ( amountSats: number, paymentHash: Uint8Array, memo?: string, receiverIdentityPubkey?: string, descriptionHash?: string, ) => Promise; amountSats: number; memo?: string; receiverIdentityPubkey?: string; descriptionHash?: string; }; export type CreateLightningInvoiceWithPreimageParams = { preimage: Uint8Array; } & CreateLightningInvoiceParams; export type SwapNodesForPreimageParams = { receiverIdentityPubkey: Uint8Array; paymentHash: Uint8Array; invoiceString?: string; isInboundPayment: boolean; feeSats?: number; amountSatsToSend?: number; startTransferRequest?: StartTransferRequest; } & IdempotencyOptions; export class LightningService { private readonly config: WalletConfigService; private readonly connectionManager: ConnectionManager; private readonly signingService: SigningService; private readonly logger: Logger; constructor( config: WalletConfigService, connectionManager: ConnectionManager, signingService: SigningService, logging = LoggingService.fromConfig(config), ) { this.config = config; this.connectionManager = connectionManager; this.signingService = signingService; this.logger = logging.logger("LightningService"); logging.wrapPrototypeMethods("LightningService", this); } async createLightningInvoice({ invoiceCreator, amountSats, memo, receiverIdentityPubkey, descriptionHash, }: CreateLightningInvoiceParams): Promise { const crypto = getCrypto(); const randBytes = crypto.getRandomValues(new Uint8Array(32)); const preimage = numberToBytesBE( bytesToNumberBE(randBytes) % secp256k1.CURVE.n, 32, ); return await this.createLightningInvoiceWithPreImage({ invoiceCreator, amountSats, memo, preimage, receiverIdentityPubkey, descriptionHash, }); } async createLightningInvoiceWithPreImage({ invoiceCreator, amountSats, memo, preimage, receiverIdentityPubkey, descriptionHash, }: CreateLightningInvoiceWithPreimageParams): Promise { const paymentHash = sha256(preimage); const invoice = await invoiceCreator( amountSats, paymentHash, memo, receiverIdentityPubkey, descriptionHash, ); if (!invoice) { throw new SparkValidationError("Failed to create lightning invoice", { field: "invoice", value: null, expected: "Non-null invoice", }); } const signingOperators = this.config.getSigningOperators(); const shares = await this.config.signer.splitSecretWithProofs({ secret: preimage, curveOrder: secp256k1.CURVE.n, threshold: this.config.getThreshold(), numShares: Object.keys(signingOperators).length, }); const sparkFrost = getSparkFrost(); const encryptedShares: Record = {}; for (const [identifier, operator] of Object.entries(signingOperators)) { const share = shares[operator.id]; if (!share) { throw new SparkValidationError("Share not found for operator", { field: "share", value: operator.id, expected: "Non-null share", }); } const shareProto: SecretShareProto = { secretShare: share.share, proofs: share.proofs, }; const shareBytes = SecretShareProto.encode(shareProto).finish(); const encrypted = await sparkFrost.encryptEcies( shareBytes, hexToBytes(operator.identityPublicKey), ); encryptedShares[identifier] = Uint8Array.from(encrypted); } const invoiceString = invoice.invoice.encodedInvoice; const threshold = this.config.getThreshold(); const userIdentityPublicKey = receiverIdentityPubkey ? hexToBytes(receiverIdentityPubkey) : await this.config.signer.getIdentityPublicKey(); const sparkClient = await this.connectionManager.createSparkClient( this.config.getCoordinatorAddress(), ); try { await sparkClient.store_preimage_share_v2({ paymentHash, encryptedPreimageShares: encryptedShares, threshold, invoiceString, userIdentityPublicKey, }); } catch (error) { throw new SparkRequestError("Failed to store preimage shares", { operation: "store_preimage_share_v2", error, }); } return invoice; } /** * Swap nodes for preimage * @param receiverIdentityPubkey - The receiver identity public key * @param paymentHash - The payment hash * @param invoiceString - The invoice string * @param isInboundPayment - Whether the payment is inbound * @param feeSats - The fee in sats * @param amountSatsToSend - The amount in sats to send * @param startTransferRequest - The start transfer request, do not populate if is inbound payment */ async swapNodesForPreimage({ receiverIdentityPubkey, paymentHash, invoiceString, isInboundPayment, feeSats = 0, amountSatsToSend, startTransferRequest, idempotencyKey, }: SwapNodesForPreimageParams): Promise { const sparkClient = await this.connectionManager.createSparkClient( this.config.getCoordinatorAddress(), ); let bolt11String = ""; let amountSats: number = 0; if (invoiceString) { const decodedInvoice = decodeInvoice(invoiceString); let amountMsats = 0; try { amountMsats = Number(decodedInvoice.amountMSats); } catch (error) { this.logger.error( `Error decoding invoice: ${ error instanceof Error ? error.message : String(error) }`, ); } const isZeroAmountInvoice = !amountMsats; if (isZeroAmountInvoice && amountSatsToSend === undefined) { throw new SparkValidationError( "Invalid amount. User must specify amountSatsToSend for 0 amount lightning invoice", { field: "amountSatsToSend", value: amountSatsToSend, expected: "positive number", }, ); } amountSats = isZeroAmountInvoice ? amountSatsToSend! : Math.ceil(amountMsats / 1000); if (isNaN(amountSats) || amountSats <= 0) { throw new SparkValidationError("Invalid amount", { field: "amountSats", value: amountSats, expected: "greater than 0", }); } bolt11String = invoiceString; } const reason = isInboundPayment ? InitiatePreimageSwapRequest_Reason.REASON_RECEIVE : InitiatePreimageSwapRequest_Reason.REASON_SEND; let response: InitiatePreimageSwapResponse; try { response = await sparkClient.initiate_preimage_swap_v3( { paymentHash, invoiceAmount: { invoiceAmountProof: { bolt11Invoice: bolt11String, }, valueSats: amountSats, }, reason, receiverIdentityPublicKey: receiverIdentityPubkey, feeSats, transferRequest: startTransferRequest, }, idempotencyKey ? optionsWithIdempotencyKey(idempotencyKey) : undefined, ); } catch (error) { throw new SparkRequestError("Failed to initiate preimage swap", { operation: "initiate_preimage_swap_v3", error, }); } return response; } async queryUserSignedRefunds( paymentHash: Uint8Array, ): Promise { const sparkClient = await this.connectionManager.createSparkClient( this.config.getCoordinatorAddress(), ); let response: QueryUserSignedRefundsResponse; try { response = await sparkClient.query_user_signed_refunds({ paymentHash, identityPublicKey: await this.config.signer.getIdentityPublicKey(), }); } catch (error) { throw new SparkRequestError("Failed to query user signed refunds", { operation: "query_user_signed_refunds", error, }); } return response.userSignedRefunds; } validateUserSignedRefund(userSignedRefund: UserSignedRefund): bigint { const refundTx = getTxFromRawTxBytes(userSignedRefund.refundTx); // TODO: Should we assert that the amount is always defined here? return refundTx.getOutput(0).amount || 0n; } async providePreimage(preimage: Uint8Array): Promise { const sparkClient = await this.connectionManager.createSparkClient( this.config.getCoordinatorAddress(), ); const paymentHash = sha256(preimage); let response: ProvidePreimageResponse; try { response = await sparkClient.provide_preimage({ preimage, paymentHash, identityPublicKey: await this.config.signer.getIdentityPublicKey(), }); } catch (error) { throw new SparkRequestError("Failed to provide preimage", { operation: "provide_preimage", error, }); } if (!response.transfer) { throw new SparkValidationError("No transfer returned from coordinator", { field: "transfer", value: response, expected: "Non-null transfer", }); } return response.transfer; } }