diff --git a/run-everything.sh b/run-everything.sh index aacd88050..28a2473e1 100755 --- a/run-everything.sh +++ b/run-everything.sh @@ -605,3 +605,7 @@ if ! check_operators_ready "$run_dir"; then echo "Failed to start all operators" exit 1 fi + +bitcoin-cli -regtest -rpcport=8332 -rpcuser="$bitcoind_username" -rpcpassword="$bitcoind_password" createwallet "" +ADDR=$(bitcoin-cli -regtest -rpcport=8332 -rpcuser="$bitcoind_username" -rpcpassword="$bitcoind_password" getnewaddress) +bitcoin-cli -regtest -rpcport=8332 -rpcuser="$bitcoind_username" -rpcpassword="$bitcoind_password" generatetoaddress 101 $ADDR diff --git a/sdks/js/packages/spark-sdk/src/services/transfer.ts b/sdks/js/packages/spark-sdk/src/services/transfer.ts index 1179240d7..9f3be3924 100644 --- a/sdks/js/packages/spark-sdk/src/services/transfer.ts +++ b/sdks/js/packages/spark-sdk/src/services/transfer.ts @@ -15,9 +15,14 @@ import { ClaimLeafKeyTweak, ClaimTransferSignRefundsResponse, CounterLeafSwapResponse, + GetSigningCommitmentsResponse, + InitiatePreimageSwapResponse, LeafRefundTxSigningJob, LeafRefundTxSigningResult, NodeSignatures, + PreimageRequestRole, + ProvidePreimageResponse, + QueryHtlcResponse, QueryTransfersResponse, RenewNodeZeroTimelockSigningJob, RenewRefundTimelockSigningJob, @@ -1910,4 +1915,155 @@ export class TransferService extends BaseTransferService { return signingJobs; } + + async createHTLC( + leaves: LeafKeyTweak[], + receiverIdentityPubkey: Uint8Array, + paymentHash: Uint8Array, + ): Promise { + const sparkClient = await this.connectionManager.createSparkClient( + this.config.getCoordinatorAddress(), + ); + + // Get signing commitments for all transaction types in one coordinated call + let signingCommitments: GetSigningCommitmentsResponse; + try { + signingCommitments = await sparkClient.get_signing_commitments({ + nodeIds: leaves.map((leaf) => leaf.leaf.id), + count: 3, + }); + } catch (error) { + throw new NetworkError( + "Failed to get signing commitments", + { + operation: "get_signing_commitments", + errorCount: 1, + errors: error instanceof Error ? error.message : String(error), + }, + error as Error, + ); + } + + const { + cpfpLeafSigningJobs, + } = await this.signingService.signRefunds( + leaves, + receiverIdentityPubkey, + signingCommitments.signingCommitments.slice(0, leaves.length), + signingCommitments.signingCommitments.slice( + leaves.length, + 2 * leaves.length, + ), + signingCommitments.signingCommitments.slice(2 * leaves.length), + ); + + const transferID = uuidv7(); + + let response: InitiatePreimageSwapResponse; + + let value = 0; + + for (const leaf of leaves) { + value += leaf.leaf.value; + } + + try { + response = await sparkClient.initiate_preimage_swap_v3({ + paymentHash: paymentHash, + transfer: { + transferId: transferID, + leavesToSend: cpfpLeafSigningJobs, + receiverIdentityPublicKey: receiverIdentityPubkey, + ownerIdentityPublicKey: + await this.config.signer.getIdentityPublicKey() + }, + receiverIdentityPublicKey: receiverIdentityPubkey, + invoiceAmount: { + valueSats: value + } + }); + } catch (error) { + throw new NetworkError( + "Failed to initiate preimage swap", + { + method: "POST", + }, + error as Error, + ); + } + + if (!response.transfer) { + throw new ValidationError("No transfer response from operator"); + } + + return response.transfer; + } + + async queryHTLCs(paymentHashes: Uint8Array[], offset?: number) { + const sparkClient = await this.connectionManager.createSparkClient( + this.config.getCoordinatorAddress(), + ); + + let response: QueryHtlcResponse; + + try { + response = await sparkClient.query_htlc({ + paymentHashes, + identityPublicKey: await this.config.signer.getIdentityPublicKey(), + matchRole: PreimageRequestRole.PREIMAGE_REQUEST_ROLE_RECEIVER, // ? + limit: 100, + offset: offset + }); + } catch (error) { + throw new NetworkError( + "Failed to query htlc", + { + method: "POST", + }, + error as Error, + ); + } + + return response.preimageRequests; + } + + async claimHTLC(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 NetworkError( + "Failed to provide preimage", + { + method: "POST", + }, + error as Error, + ); + } + + if (!response.transfer) { + throw new ValidationError("No transfer response from operator"); + } + + return response.transfer; + } + + generateRandomPreimage(): Uint8Array { + const preimage = new Uint8Array(32); + + crypto.getRandomValues(preimage); + + return preimage; + } } diff --git a/sdks/js/packages/spark-sdk/src/spark-wallet/spark-wallet.ts b/sdks/js/packages/spark-sdk/src/spark-wallet/spark-wallet.ts index 860afc9a7..00c66e8a5 100644 --- a/sdks/js/packages/spark-sdk/src/spark-wallet/spark-wallet.ts +++ b/sdks/js/packages/spark-sdk/src/spark-wallet/spark-wallet.ts @@ -986,9 +986,9 @@ export abstract class SparkWallet extends EventEmitter { } as const; const senderPublicKey = senderSparkAddress ? hexToBytes( - decodeSparkAddress(senderSparkAddress, this.config.getNetworkType()) - .identityPublicKey, - ) + decodeSparkAddress(senderSparkAddress, this.config.getNetworkType()) + .identityPublicKey, + ) : undefined; const invoiceFields = { version: 1, @@ -1064,9 +1064,9 @@ export abstract class SparkWallet extends EventEmitter { } as const; const senderPublicKey = senderSparkAddress ? hexToBytes( - decodeSparkAddress(senderSparkAddress, this.config.getNetworkType()) - .identityPublicKey, - ) + decodeSparkAddress(senderSparkAddress, this.config.getNetworkType()) + .identityPublicKey, + ) : undefined; const invoiceFields = { version: 1, @@ -2918,6 +2918,34 @@ export abstract class SparkWallet extends EventEmitter { return outcome.transfer; } + // Select and renew leaves for a transfer. + private async selectAndRenewLeaves(amountSatsArray: number[]): Promise> { + const selectLeaves: Map = + await this.selectLeaves(amountSatsArray); + + // Renew leaves as necessary. + for (const [amount, selection] of selectLeaves) { + for (let groupIndex = 0; groupIndex < selection.length; groupIndex++) { + const group = selection[groupIndex]; + if (!group) { + throw new ValidationError( + `TreeNode group at index ${groupIndex} not found for amount ${amount} after selection`, + ); + } + const available = await this.checkRenewLeaves(group); + + if (available.length < group.length) { + throw new Error( + `Not enough available nodes after refresh/extend. Expected ${group.length}, got ${available.length}`, + ); + } + selection[groupIndex] = available; + } + } + + return selectLeaves; + } + /** * Transfers with optional invoices. * Does not parse/validate invoices or enforce amount-vs-invoice. @@ -2953,26 +2981,7 @@ export abstract class SparkWallet extends EventEmitter { return await this.withLeaves(async () => { const selectLeavesToSendMap: Map = - await this.selectLeaves(amountSatsArray); - - for (const [amount, selection] of selectLeavesToSendMap) { - for (let groupIndex = 0; groupIndex < selection.length; groupIndex++) { - const group = selection[groupIndex]; - if (!group) { - throw new ValidationError( - `TreeNode group at index ${groupIndex} not found for amount ${amount} after selection`, - ); - } - const available = await this.checkRenewLeaves(group); - - if (available.length < group.length) { - throw new Error( - `Not enough available nodes after refresh/extend. Expected ${group.length}, got ${available.length}`, - ); - } - selection[groupIndex] = available; - } - } + await this.selectAndRenewLeaves(amountSatsArray); const tweaksByAmount = this.buildTweaksByAmount(selectLeavesToSendMap); @@ -3371,13 +3380,13 @@ export abstract class SparkWallet extends EventEmitter { if ( transfer.status !== TransferStatus.TRANSFER_STATUS_SENDER_KEY_TWEAKED && transfer.status !== - TransferStatus.TRANSFER_STATUS_RECEIVER_KEY_TWEAKED && + TransferStatus.TRANSFER_STATUS_RECEIVER_KEY_TWEAKED && transfer.status !== - TransferStatus.TRANSFER_STATUS_RECEIVER_REFUND_SIGNED && + TransferStatus.TRANSFER_STATUS_RECEIVER_REFUND_SIGNED && transfer.status !== - TransferStatus.TRANSFER_STATUS_RECEIVER_KEY_TWEAK_APPLIED && + TransferStatus.TRANSFER_STATUS_RECEIVER_KEY_TWEAK_APPLIED && transfer.status !== - TransferStatus.TRANSFER_STATUS_RECEIVER_KEY_TWEAK_LOCKED + TransferStatus.TRANSFER_STATUS_RECEIVER_KEY_TWEAK_LOCKED ) { continue; } @@ -3858,17 +3867,17 @@ export abstract class SparkWallet extends EventEmitter { await this.syncTokenOutputs(); const tokenTransferTasks: Promise< | { - ok: true; - tokenIdentifier: Bech32mTokenIdentifier; - invoices: SparkAddressFormat[]; - txid: string; - } + ok: true; + tokenIdentifier: Bech32mTokenIdentifier; + invoices: SparkAddressFormat[]; + txid: string; + } | { - ok: false; - tokenIdentifier: Bech32mTokenIdentifier; - invoices: SparkAddressFormat[]; - error: Error; - } + ok: false; + tokenIdentifier: Bech32mTokenIdentifier; + invoices: SparkAddressFormat[]; + error: Error; + } >[] = []; for (const [identifierHex, decodedInvoices] of tokenInvoices.entries()) { const tokenIdentifier = hexToBytes(identifierHex); @@ -4117,6 +4126,112 @@ export abstract class SparkWallet extends EventEmitter { }); } + /** + * Queries HTLCs (Hash Time Locked Contracts) by payment hashes. + * + * @param {Uint8Array[]} paymentHashes - Array of payment hashes to query + * @param {number} [offset] - Optional offset for pagination + * @returns {Promise} Array of preimage requests + */ + public async queryHTLCs(paymentHashes: Uint8Array[], offset?: number) { + return await this.transferService.queryHTLCs(paymentHashes, offset); + } + + /** + * Creates an HTLC (Hash Time Locked Contract) transfer. + * + * @param {CreateHTLCParams} params - Parameters for the transfer + * @param {string} params.receiverSparkAddress - The recipient's Spark address + * @param {number} params.amountSats - Amount to send in satoshis + * @param {Uint8Array} params.paymentHash - Payment hash of the HTLC + * @returns {Promise} The completed transfer details + */ + public async createHTLC({ + receiverSparkAddress, + amountSats, + paymentHash, + }: { + receiverSparkAddress: string; + amountSats: number; + paymentHash: Uint8Array; + }): Promise { + const { identityPublicKey: receiverIdentityPubkey } = decodeSparkAddress( + receiverSparkAddress, + this.config.getNetworkType(), + ); + + return await this.withLeaves(async () => { + const selectLeavesToSendMap: Map = + await this.selectAndRenewLeaves([amountSats]); + + const tweaksByAmount = this.buildTweaksByAmount(selectLeavesToSendMap); + + const leafKeyTweaks = this.popOrThrow( + tweaksByAmount.get(amountSats), + `no leaves key tweaks for ${amountSats}`, + ); + + return await this.transferService.createHTLC( + leafKeyTweaks, + hexToBytes(receiverIdentityPubkey), + paymentHash, + ); + }); + } + + /** + * Claims an HTLC (Hash Time Locked Contract) by providing the preimage. + * + * @param {Uint8Array} preimage - The preimage that hashes to the payment hash + * @returns {Promise} The claimed transfer + */ + public async claimHTLC(preimage: Uint8Array): Promise { + return await this.transferService.claimHTLC(preimage); + } + + /** + * Generates a random 32-byte preimage for HTLC operations. + * + * @returns {Uint8Array} A random 32-byte preimage + */ + public generateRandomPreimage(): Uint8Array { + return this.transferService.generateRandomPreimage(); + } + + /** + * Delivers the transfer package for an HTLC transfer. + * This should be called after the receiver has claimed the HTLC (revealed the preimage). + * + * @param {Transfer} transfer - The transfer to deliver the package for + * @returns {Promise} The updated transfer + */ + public async deliverTransferPackage(transfer: Transfer): Promise { + return await this.withLeaves(async () => { + const leaves: LeafKeyTweak[] = []; + for (const transferLeaf of transfer.leaves) { + if (!transferLeaf.leaf) { + throw new ValidationError("Transfer leaf missing node data"); + } + leaves.push({ + leaf: transferLeaf.leaf, + keyDerivation: { + type: KeyDerivationType.LEAF, + path: transferLeaf.leaf.id, + }, + newKeyDerivation: { type: KeyDerivationType.RANDOM }, + }); + } + + return await this.transferService.deliverTransferPackage( + transfer, + leaves, + new Map(), + new Map(), + new Map(), + ); + }); + } + /** * Gets fee estimate for sending Lightning payments. * @@ -4262,11 +4377,11 @@ export abstract class SparkWallet extends EventEmitter { if (deductFeeFromWithdrawalAmount) { leavesToSendToSsp = targetAmountSats ? this.popOrThrow( - (await this.selectLeaves([targetAmountSats])).get( - targetAmountSats, - )!, - `no leaves for ${targetAmountSats}`, - ) + (await this.selectLeaves([targetAmountSats])).get( + targetAmountSats, + )!, + `no leaves for ${targetAmountSats}`, + ) : this.leaves; if ( @@ -5402,8 +5517,8 @@ type SparkWalletFunctionKeys = Extract< [K in keyof SparkWallet]: SparkWallet[K] extends ( ...args: any[] ) => PromiseLike - ? K - : never; + ? K + : never; }[keyof SparkWallet], string >; @@ -5418,12 +5533,15 @@ const PUBLIC_SPARK_WALLET_METHODS = [ "batchTransferTokens", "checkTimelock", "claimDeposit", + "claimHTLC", "claimStaticDeposit", "claimStaticDepositWithMaxFee", "cleanupConnections", + "createHTLC", "createLightningInvoice", "createSatsInvoice", "createTokensInvoice", + "deliverTransferPackage", "fulfillSparkInvoice", "getBalance", "getClaimStaticDepositQuote", @@ -5450,6 +5568,7 @@ const PUBLIC_SPARK_WALLET_METHODS = [ "isTokenOptimizationInProgress", "optimizeTokenOutputs", "payLightningInvoice", + "queryHTLCs", "querySparkInvoices", "queryStaticDepositAddresses", "queryTokenTransactions", diff --git a/sdks/js/packages/spark-sdk/src/tests/integration/htlc.test.ts b/sdks/js/packages/spark-sdk/src/tests/integration/htlc.test.ts new file mode 100644 index 000000000..729fe8c0e --- /dev/null +++ b/sdks/js/packages/spark-sdk/src/tests/integration/htlc.test.ts @@ -0,0 +1,107 @@ +import { BitcoinFaucet, createNewTree } from "../test-utils.js"; +import { type ConfigOptions } from "../../services/wallet-config.js"; +import { DefaultSparkSigner, KeyDerivation, KeyDerivationType } from "../../index-shared.js"; +import { SparkWalletTestingIntegration } from "../utils/spark-testing-wallet.js"; +import { uuidv7 } from "uuidv7"; +import { LeafKeyTweak } from "../../services/transfer.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; +import { sha256 } from "@noble/hashes/sha2"; + +test("htlc", async () => { + const faucet = BitcoinFaucet.getInstance(); + + const options: ConfigOptions = { + network: "LOCAL", + }; + + const { wallet: senderWallet } = + await SparkWalletTestingIntegration.initialize({ + options, + signer: new DefaultSparkSigner(), + }); + + const senderTransferService = senderWallet.getTransferService(); + + const leafId = uuidv7(); + const rootNode = await createNewTree(senderWallet, leafId, faucet, 1000n); + + const newLeafDerivationPath: KeyDerivation = { + type: KeyDerivationType.LEAF, + path: uuidv7(), + }; + + const { wallet: receiverWallet } = + await SparkWalletTestingIntegration.initialize({ + options, + signer: new DefaultSparkSigner(), + }); + const receiverPubkey = await receiverWallet.getIdentityPublicKey(); + + const receiverTransferService = receiverWallet.getTransferService(); + + const transferNode: LeafKeyTweak = { + leaf: rootNode, + keyDerivation: { + type: KeyDerivationType.LEAF, + path: leafId, + }, + newKeyDerivation: newLeafDerivationPath, + }; + + const preimage = senderWallet.generateRandomPreimage(); + const paymentHash = sha256(preimage); + + const leavesToSend = [transferNode]; + + const senderTransfer = await senderTransferService.createHTLC( + leavesToSend, + hexToBytes(receiverPubkey), + paymentHash + ); + + const htlcs = + await receiverWallet.queryHTLCs([paymentHash]); + expect(htlcs.length).toBe(1); + expect(htlcs[0]!.transfer!.id).toBe(senderTransfer.id); + + await receiverWallet.claimHTLC(preimage); + + await senderWallet.deliverTransferPackage(senderTransfer); + + const pendingTransfer = await receiverWallet.queryPendingTransfers(); + expect(pendingTransfer.transfers.length).toBe(1); + + const receiverTransfer = pendingTransfer.transfers[0]; + expect(receiverTransfer!.id).toBe(senderTransfer.id); + + const leafPrivKeyMap = await receiverWallet.verifyPendingTransfer( + receiverTransfer!, + ); + + expect(leafPrivKeyMap.size).toBe(1); + + const leafPrivKeyMapBytes = leafPrivKeyMap.get(rootNode.id); + expect(leafPrivKeyMapBytes).toBeDefined(); + + const claimingNodes: LeafKeyTweak[] = receiverTransfer!.leaves.map( + (leaf) => ({ + leaf: leaf.leaf!, + keyDerivation: { + type: KeyDerivationType.ECIES, + path: leaf.secretCipher, + }, + newKeyDerivation: { + type: KeyDerivationType.LEAF, + path: leaf.leaf!.id, + }, + }), + ); + + await receiverTransferService.claimTransfer( + receiverTransfer!, + claimingNodes, + ); + + const balance = await receiverWallet.getBalance(); + expect(balance.balance).toBe(1000n); +}); diff --git a/sdks/js/packages/spark-sdk/src/tests/integration/transfer.test.ts b/sdks/js/packages/spark-sdk/src/tests/integration/transfer.test.ts index de7dd3316..408129641 100644 --- a/sdks/js/packages/spark-sdk/src/tests/integration/transfer.test.ts +++ b/sdks/js/packages/spark-sdk/src/tests/integration/transfer.test.ts @@ -20,8 +20,7 @@ import { SparkWalletTestingIntegrationWithStream, } from "../utils/spark-testing-wallet.js"; import { BitcoinFaucet } from "../utils/test-faucet.js"; - -const testLocalOnly = process.env.GITHUB_ACTIONS ? it.skip : it; +import { testMinikubeAndLocalOnly } from "../utils/utils.js"; describe.each(walletTypes)( "Transfer with name", @@ -121,7 +120,7 @@ describe.each(walletTypes)( expect(balance.balance).toBe(1000n); }, 30000); - testLocalOnly(`${name} - test transfer with separate`, async () => { + testMinikubeAndLocalOnly(`${name} - test transfer with separate`, async () => { const faucet = BitcoinFaucet.getInstance(); const options: ConfigOptions = { @@ -252,7 +251,7 @@ describe.each(walletTypes)( ); }); - testLocalOnly( + testMinikubeAndLocalOnly( `${name} - test that when the receiver has tweaked the key on some SOs, we can still claim the transfer`, async () => { const faucet = BitcoinFaucet.getInstance(); diff --git a/sdks/js/packages/spark-sdk/src/tests/integration/unilateral-exit.test.ts b/sdks/js/packages/spark-sdk/src/tests/integration/unilateral-exit.test.ts index 9eb74c4ba..4e6e12e7d 100644 --- a/sdks/js/packages/spark-sdk/src/tests/integration/unilateral-exit.test.ts +++ b/sdks/js/packages/spark-sdk/src/tests/integration/unilateral-exit.test.ts @@ -5,7 +5,7 @@ import { RPCError } from "../../errors/types.js"; import { Network } from "../../utils/network.js"; import { SparkWalletTestingIntegration } from "../utils/spark-testing-wallet.js"; import { BitcoinFaucet } from "../utils/test-faucet.js"; -import { waitForClaim } from "../utils/utils.js"; +import { testMinikubeOnly, waitForClaim } from "../utils/utils.js"; import { constructUnilateralExitFeeBumpPackages, hash160, @@ -14,7 +14,7 @@ import { signPsbtWithExternalKey } from "../utils/signing.js"; import { TreeNode } from "../../proto/spark.js"; describe("unilateral exit", () => { - it("should unilateral exit", async () => { + testMinikubeOnly("should unilateral exit", async () => { const faucet = BitcoinFaucet.getInstance(); const { wallet: userWallet } = diff --git a/sdks/js/packages/spark-sdk/src/tests/utils/utils.ts b/sdks/js/packages/spark-sdk/src/tests/utils/utils.ts index 635e1b487..c623de777 100644 --- a/sdks/js/packages/spark-sdk/src/tests/utils/utils.ts +++ b/sdks/js/packages/spark-sdk/src/tests/utils/utils.ts @@ -61,3 +61,7 @@ export async function waitForClaim({ wallet.once(SparkWalletEvent.TransferClaimed, onClaim); }); } + +export const isMinikube = process.env.MINIKUBE_IP != undefined; +export const testMinikubeOnly = !isMinikube ? it.skip : it; +export const testMinikubeAndLocalOnly = process.env.GITHUB_ACTIONS || !isMinikube ? it.skip : it; diff --git a/spark/so/grpc_test/transfer_test.go b/spark/so/grpc_test/transfer_test.go index 5f6608902..f19635be3 100644 --- a/spark/so/grpc_test/transfer_test.go +++ b/spark/so/grpc_test/transfer_test.go @@ -91,6 +91,102 @@ func TestTransfer(t *testing.T) { require.Equal(t, res[0].Id, claimingNode.Leaf.Id) } +func TestHTLC(t *testing.T) { + // Sender initiates transfer + senderConfig := wallet.NewTestWalletConfig(t) + leafPrivKey := keys.GeneratePrivateKey() + rootNode, err := wallet.CreateNewTree(senderConfig, faucet, leafPrivKey, amountSatsToSend) + require.NoError(t, err, "failed to create new tree") + + newLeafPrivKey := keys.GeneratePrivateKey() + receiverPrivKey := keys.GeneratePrivateKey() + + transferNode := wallet.LeafKeyTweak{ + Leaf: rootNode, + SigningPrivKey: leafPrivKey, + NewSigningPrivKey: newLeafPrivKey, + } + leavesToTransfer := [1]wallet.LeafKeyTweak{transferNode} + + conn, err := sparktesting.DangerousNewGRPCConnectionWithoutVerifyTLS(senderConfig.CoordinatorAddress(), nil) + require.NoError(t, err, "failed to create grpc connection") + defer conn.Close() + + authToken, err := wallet.AuthenticateWithServer(t.Context(), senderConfig) + require.NoError(t, err, "failed to authenticate sender") + senderCtx := wallet.ContextWithToken(t.Context(), authToken) + + preimage := []byte("test_payment_hash_32_bytes_long_") + paymentHash := sha256.Sum256(preimage) + + // Create the HTLC. + transfer, err := wallet.InitiatePreimageSwapV3( + senderCtx, + senderConfig, + leavesToTransfer[:], + receiverPrivKey.Public(), + paymentHash[:], + time.Now().Add(10*time.Minute), + ) + require.NoError(t, err) + + receiverConfig := wallet.NewTestWalletConfigWithIdentityKey(t, receiverPrivKey) + require.NoError(t, err, "failed to create wallet config") + receiverToken, err := wallet.AuthenticateWithServer(t.Context(), receiverConfig) + require.NoError(t, err, "failed to authenticate receiver") + receiverCtx := wallet.ContextWithToken(t.Context(), receiverToken) + + // Claim the HTLC. + _, err = wallet.ProvidePreimage(receiverCtx, receiverConfig, preimage) + require.NoError(t, err) + + // Sender delivers the transfer package. + _, err = wallet.DeliverTransferPackage( + senderCtx, + senderConfig, + transfer, + leavesToTransfer[:], + nil, + ) + require.NoError(t, err) + + // Receiver queries the transfer. + pendingTransfers, err := wallet.QueryPendingTransfers(receiverCtx, receiverConfig) + require.NoError(t, err) + require.Len(t, pendingTransfers.Transfers, 1) + receiverTransfer := pendingTransfers.Transfers[0] + + // Decrypt the transferred private key. + leafPrivKeyMap, err := wallet.VerifyPendingTransfer(t.Context(), receiverConfig, receiverTransfer) + require.NoError(t, err) + require.Len(t, leafPrivKeyMap, 1) + + // Get the transferred private key for the leaf. + transferredPrivKey, ok := leafPrivKeyMap[receiverTransfer.Leaves[0].Leaf.Id] + require.True(t, ok, "expected to find private key for leaf") + + newLeafPrivKey = keys.GeneratePrivateKey() + + claimingNode := wallet.LeafKeyTweak{ + Leaf: receiverTransfer.Leaves[0].Leaf, + SigningPrivKey: transferredPrivKey, // Use the decrypted transferred key + NewSigningPrivKey: newLeafPrivKey, + } + + claimedNodes, err := wallet.ClaimTransfer( + receiverCtx, + receiverTransfer, + receiverConfig, + []wallet.LeafKeyTweak{claimingNode}, + ) + + require.NoError(t, err) + require.Len(t, claimedNodes, 1) + require.Equal(t, uint64(amountSatsToSend), claimedNodes[0].Value) + + t.Logf("Receiver successfully claimed %d sats", claimedNodes[0].Value) +} + func TestQueryPendingTransferByNetwork(t *testing.T) { senderConfig := wallet.NewTestWalletConfig(t) leafPrivKey := keys.GeneratePrivateKey() diff --git a/spark/testing/wallet/transfer.go b/spark/testing/wallet/transfer.go index d2e5b3479..a0a59c112 100644 --- a/spark/testing/wallet/transfer.go +++ b/spark/testing/wallet/transfer.go @@ -1501,3 +1501,104 @@ func InitiateSwapPrimaryTransfer( } return response, nil } + +func InitiatePreimageSwapV3( + ctx context.Context, + config *TestWalletConfig, + leaves []LeafKeyTweak, + receiverIdentityPubkey keys.Public, + paymentHash []byte, + expiryTime time.Time, +) (*pb.Transfer, error) { + sparkConn, err := config.NewCoordinatorGRPCConnection() + if err != nil { + return nil, err + } + defer sparkConn.Close() + + token, err := AuthenticateWithConnection(ctx, config, sparkConn) + if err != nil { + return nil, fmt.Errorf("failed to authenticate with server: %w", err) + } + authCtx := ContextWithToken(ctx, token) + + client := pb.NewSparkServiceClient(sparkConn) + + transferID, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("failed to generate transfer id: %w", err) + } + + // Get signing commitments from the server + nodes := make([]string, len(leaves)) + for i, leaf := range leaves { + nodes[i] = leaf.Leaf.Id + } + signingCommitments, err := client.GetSigningCommitments(authCtx, &pb.GetSigningCommitmentsRequest{ + NodeIds: nodes, + }) + if err != nil { + return nil, fmt.Errorf("failed to get signing commitments: %w", err) + } + + // Create FROST signer connection + signerConn, err := config.NewFrostGRPCConnection() + if err != nil { + return nil, err + } + defer signerConn.Close() + signerClient := pbfrost.NewFrostServiceClient(signerConn) + + // Create CPFP refund transactions and sign them + cpfpSigningJobs, cpfpRefundTxs, cpfpUserCommitments, err := prepareFrostSigningJobsForUserSignedRefund(leaves, signingCommitments.SigningCommitments, receiverIdentityPubkey, keys.Public{}) + if err != nil { + return nil, fmt.Errorf("failed to prepare signing jobs: %w", err) + } + + cpfpSigningResults, err := signerClient.SignFrost(authCtx, &pbfrost.SignFrostRequest{ + SigningJobs: cpfpSigningJobs, + Role: pbfrost.SigningRole_USER, + }) + if err != nil { + return nil, fmt.Errorf("failed to sign refund transactions: %w", err) + } + + // Prepare leaf signing jobs with all required fields + leavesToSend, err := prepareLeafSigningJobs( + leaves, + cpfpRefundTxs, + cpfpSigningResults.Results, + cpfpUserCommitments, + signingCommitments.SigningCommitments, + ) + if err != nil { + return nil, fmt.Errorf("failed to prepare leaf signing jobs: %w", err) + } + + var value uint64 + + for _, leaf := range leaves { + value += leaf.Leaf.Value + } + + resp, err := client.InitiatePreimageSwapV3(authCtx, &pb.InitiatePreimageSwapRequest{ + PaymentHash: paymentHash, + Transfer: &pb.StartUserSignedTransferRequest{ + TransferId: transferID.String(), + LeavesToSend: leavesToSend, + ReceiverIdentityPublicKey: receiverIdentityPubkey.Serialize(), + OwnerIdentityPublicKey: config.IdentityPrivateKey.Public().Serialize(), + ExpiryTime: timestamppb.New(expiryTime), + }, + ReceiverIdentityPublicKey: receiverIdentityPubkey.Serialize(), + InvoiceAmount: &pb.InvoiceAmount{ + ValueSats: value, + }, + Reason: pb.InitiatePreimageSwapRequest_REASON_SEND, + }) + if err != nil { + return nil, err + } + + return resp.Transfer, nil +} diff --git a/stop-everything.sh b/stop-everything.sh new file mode 100755 index 000000000..d1b9c147f --- /dev/null +++ b/stop-everything.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +tmux kill-session -t bitcoind +tmux kill-session -t operators +tmux kill-session -t frost-signers +tmux kill-session -t electrs