@flashnet/sdk 0.3.40 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.d.ts +3 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +10 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/src/api/client.d.ts.map +1 -1
- package/dist/cjs/src/api/client.js +14 -15
- package/dist/cjs/src/api/client.js.map +1 -1
- package/dist/cjs/src/api/typed-endpoints.d.ts +9 -1
- package/dist/cjs/src/api/typed-endpoints.d.ts.map +1 -1
- package/dist/cjs/src/api/typed-endpoints.js +6 -2
- package/dist/cjs/src/api/typed-endpoints.js.map +1 -1
- package/dist/cjs/src/client/FlashnetClient.d.ts +232 -1
- package/dist/cjs/src/client/FlashnetClient.d.ts.map +1 -1
- package/dist/cjs/src/client/FlashnetClient.js +905 -109
- package/dist/cjs/src/client/FlashnetClient.js.map +1 -1
- package/dist/cjs/src/types/errors.d.ts +264 -0
- package/dist/cjs/src/types/errors.d.ts.map +1 -0
- package/dist/cjs/src/types/errors.js +758 -0
- package/dist/cjs/src/types/errors.js.map +1 -0
- package/dist/cjs/src/types/index.d.ts +1 -1
- package/dist/cjs/src/types/index.d.ts.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/src/api/client.d.ts.map +1 -1
- package/dist/esm/src/api/client.js +14 -15
- package/dist/esm/src/api/client.js.map +1 -1
- package/dist/esm/src/api/typed-endpoints.d.ts +9 -1
- package/dist/esm/src/api/typed-endpoints.d.ts.map +1 -1
- package/dist/esm/src/api/typed-endpoints.js +6 -2
- package/dist/esm/src/api/typed-endpoints.js.map +1 -1
- package/dist/esm/src/client/FlashnetClient.d.ts +232 -1
- package/dist/esm/src/client/FlashnetClient.d.ts.map +1 -1
- package/dist/esm/src/client/FlashnetClient.js +905 -109
- package/dist/esm/src/client/FlashnetClient.js.map +1 -1
- package/dist/esm/src/types/errors.d.ts +264 -0
- package/dist/esm/src/types/errors.d.ts.map +1 -0
- package/dist/esm/src/types/errors.js +749 -0
- package/dist/esm/src/types/errors.js.map +1 -0
- package/dist/esm/src/types/index.d.ts +1 -1
- package/dist/esm/src/types/index.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -8,6 +8,7 @@ import { getHexFromUint8Array } from '../utils/hex.js';
|
|
|
8
8
|
import { generateConstantProductPoolInitializationIntentMessage, generatePoolInitializationIntentMessage, generatePoolConfirmInitialDepositIntentMessage, generatePoolSwapIntentMessage, generateRouteSwapIntentMessage, generateAddLiquidityIntentMessage, generateRemoveLiquidityIntentMessage, generateRegisterHostIntentMessage, generateWithdrawHostFeesIntentMessage, generateWithdrawIntegratorFeesIntentMessage, generateCreateEscrowIntentMessage, generateFundEscrowIntentMessage, generateClaimEscrowIntentMessage, generateClawbackIntentMessage } from '../utils/intents.js';
|
|
9
9
|
import { getSparkNetworkFromAddress, encodeSparkAddressNew } from '../utils/spark-address.js';
|
|
10
10
|
import { encodeSparkHumanReadableTokenIdentifier, decodeSparkHumanReadableTokenIdentifier } from '../utils/tokenAddress.js';
|
|
11
|
+
import { FlashnetError } from '../types/errors.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* FlashnetClient - A comprehensive client for interacting with Flashnet AMM
|
|
@@ -525,10 +526,14 @@ class FlashnetClient {
|
|
|
525
526
|
assetAddress: params.assetAAddress,
|
|
526
527
|
amount: assetAInitialReserve,
|
|
527
528
|
});
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
529
|
+
// Execute confirm with auto-clawback on failure
|
|
530
|
+
await this.executeWithAutoClawback(async () => {
|
|
531
|
+
const confirmResponse = await this.confirmInitialDeposit(createResponse.poolId, assetATransferId, poolOwnerPublicKey);
|
|
532
|
+
if (!confirmResponse.confirmed) {
|
|
533
|
+
throw new Error(`Failed to confirm initial deposit: ${confirmResponse.message}`);
|
|
534
|
+
}
|
|
535
|
+
return confirmResponse;
|
|
536
|
+
}, [assetATransferId], createResponse.poolId);
|
|
532
537
|
return createResponse;
|
|
533
538
|
}
|
|
534
539
|
/**
|
|
@@ -575,6 +580,9 @@ class FlashnetClient {
|
|
|
575
580
|
}
|
|
576
581
|
/**
|
|
577
582
|
* Execute a swap
|
|
583
|
+
*
|
|
584
|
+
* If the swap fails with a clawbackable error, the SDK will automatically
|
|
585
|
+
* attempt to recover the transferred funds via clawback.
|
|
578
586
|
*/
|
|
579
587
|
async executeSwap(params) {
|
|
580
588
|
await this.ensureInitialized();
|
|
@@ -596,11 +604,11 @@ class FlashnetClient {
|
|
|
596
604
|
assetAddress: params.assetInAddress,
|
|
597
605
|
amount: params.amountIn,
|
|
598
606
|
}, "Insufficient balance for swap: ");
|
|
599
|
-
|
|
607
|
+
// Execute with auto-clawback on failure
|
|
608
|
+
return this.executeWithAutoClawback(() => this.executeSwapIntent({
|
|
600
609
|
...params,
|
|
601
610
|
transferId,
|
|
602
|
-
});
|
|
603
|
-
return response;
|
|
611
|
+
}), [transferId], params.poolId);
|
|
604
612
|
}
|
|
605
613
|
async executeSwapIntent(params) {
|
|
606
614
|
await this.ensureInitialized();
|
|
@@ -647,10 +655,27 @@ class FlashnetClient {
|
|
|
647
655
|
// Check if the swap was accepted
|
|
648
656
|
if (!response.accepted) {
|
|
649
657
|
const errorMessage = response.error || "Swap rejected by the AMM";
|
|
650
|
-
const
|
|
658
|
+
const hasRefund = !!response.refundedAmount;
|
|
659
|
+
const refundInfo = hasRefund
|
|
651
660
|
? ` Refunded ${response.refundedAmount} of ${response.refundedAssetAddress} via transfer ${response.refundTransferId}`
|
|
652
661
|
: "";
|
|
653
|
-
|
|
662
|
+
// If refund was provided, funds are safe - use auto_refund recovery
|
|
663
|
+
// If no refund, funds may need clawback
|
|
664
|
+
throw new FlashnetError(`${errorMessage}.${refundInfo}`, {
|
|
665
|
+
response: {
|
|
666
|
+
errorCode: hasRefund ? "FSAG-4202" : "UNKNOWN", // Slippage if refunded
|
|
667
|
+
errorCategory: hasRefund ? "Business" : "System",
|
|
668
|
+
message: `${errorMessage}.${refundInfo}`,
|
|
669
|
+
requestId: "",
|
|
670
|
+
timestamp: new Date().toISOString(),
|
|
671
|
+
service: "amm-gateway",
|
|
672
|
+
severity: "Error",
|
|
673
|
+
},
|
|
674
|
+
httpStatus: 400,
|
|
675
|
+
// Don't include transferIds if refunded - no clawback needed
|
|
676
|
+
transferIds: hasRefund ? [] : [params.transferId],
|
|
677
|
+
lpIdentityPublicKey: params.poolId,
|
|
678
|
+
});
|
|
654
679
|
}
|
|
655
680
|
return response;
|
|
656
681
|
}
|
|
@@ -667,6 +692,9 @@ class FlashnetClient {
|
|
|
667
692
|
}
|
|
668
693
|
/**
|
|
669
694
|
* Execute a route swap (multi-hop swap)
|
|
695
|
+
*
|
|
696
|
+
* If the route swap fails with a clawbackable error, the SDK will automatically
|
|
697
|
+
* attempt to recover the transferred funds via clawback.
|
|
670
698
|
*/
|
|
671
699
|
async executeRouteSwap(params) {
|
|
672
700
|
await this.ensureInitialized();
|
|
@@ -703,68 +731,85 @@ class FlashnetClient {
|
|
|
703
731
|
assetAddress: params.initialAssetAddress,
|
|
704
732
|
amount: params.inputAmount,
|
|
705
733
|
}, "Insufficient balance for route swap: ");
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
hop.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
hop.
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
734
|
+
// Execute with auto-clawback on failure
|
|
735
|
+
return this.executeWithAutoClawback(async () => {
|
|
736
|
+
// Prepare hops for validation
|
|
737
|
+
const hops = params.hops.map((hop) => ({
|
|
738
|
+
lpIdentityPublicKey: hop.poolId,
|
|
739
|
+
inputAssetAddress: this.toHexTokenIdentifier(hop.assetInAddress),
|
|
740
|
+
outputAssetAddress: this.toHexTokenIdentifier(hop.assetOutAddress),
|
|
741
|
+
hopIntegratorFeeRateBps: hop.hopIntegratorFeeRateBps !== undefined &&
|
|
742
|
+
hop.hopIntegratorFeeRateBps !== null
|
|
743
|
+
? hop.hopIntegratorFeeRateBps.toString()
|
|
744
|
+
: "0",
|
|
745
|
+
}));
|
|
746
|
+
// Convert hops and ensure integrator fee is always present
|
|
747
|
+
const requestHops = params.hops.map((hop) => ({
|
|
748
|
+
poolId: hop.poolId,
|
|
749
|
+
assetInAddress: this.toHexTokenIdentifier(hop.assetInAddress),
|
|
750
|
+
assetOutAddress: this.toHexTokenIdentifier(hop.assetOutAddress),
|
|
751
|
+
hopIntegratorFeeRateBps: hop.hopIntegratorFeeRateBps !== undefined &&
|
|
752
|
+
hop.hopIntegratorFeeRateBps !== null
|
|
753
|
+
? hop.hopIntegratorFeeRateBps.toString()
|
|
754
|
+
: "0",
|
|
755
|
+
}));
|
|
756
|
+
// Generate route swap intent
|
|
757
|
+
const nonce = generateNonce();
|
|
758
|
+
const intentMessage = generateRouteSwapIntentMessage({
|
|
759
|
+
userPublicKey: this.publicKey,
|
|
760
|
+
hops: hops.map((hop) => ({
|
|
761
|
+
lpIdentityPublicKey: hop.lpIdentityPublicKey,
|
|
762
|
+
inputAssetAddress: hop.inputAssetAddress,
|
|
763
|
+
outputAssetAddress: hop.outputAssetAddress,
|
|
764
|
+
hopIntegratorFeeRateBps: hop.hopIntegratorFeeRateBps,
|
|
765
|
+
})),
|
|
766
|
+
initialSparkTransferId: initialTransferId,
|
|
767
|
+
inputAmount: params.inputAmount.toString(),
|
|
768
|
+
maxRouteSlippageBps: params.maxRouteSlippageBps.toString(),
|
|
769
|
+
minAmountOut: params.minAmountOut,
|
|
770
|
+
nonce,
|
|
771
|
+
defaultIntegratorFeeRateBps: params.integratorFeeRateBps?.toString(),
|
|
772
|
+
});
|
|
773
|
+
// Sign intent
|
|
774
|
+
const messageHash = new Uint8Array(await crypto.subtle.digest("SHA-256", intentMessage));
|
|
775
|
+
const signature = await this._wallet.config.signer.signMessageWithIdentityKey(messageHash, true);
|
|
776
|
+
const request = {
|
|
777
|
+
userPublicKey: this.publicKey,
|
|
778
|
+
hops: requestHops,
|
|
779
|
+
initialSparkTransferId: initialTransferId,
|
|
780
|
+
inputAmount: params.inputAmount.toString(),
|
|
781
|
+
maxRouteSlippageBps: params.maxRouteSlippageBps.toString(),
|
|
782
|
+
minAmountOut: params.minAmountOut,
|
|
783
|
+
nonce,
|
|
784
|
+
signature: getHexFromUint8Array(signature),
|
|
785
|
+
integratorFeeRateBps: params.integratorFeeRateBps?.toString() || "0",
|
|
786
|
+
integratorPublicKey: params.integratorPublicKey || "",
|
|
787
|
+
};
|
|
788
|
+
const response = await this.typedApi.executeRouteSwap(request);
|
|
789
|
+
// Check if the route swap was accepted
|
|
790
|
+
if (!response.accepted) {
|
|
791
|
+
const errorMessage = response.error || "Route swap rejected by the AMM";
|
|
792
|
+
const hasRefund = !!response.refundedAmount;
|
|
793
|
+
const refundInfo = hasRefund
|
|
794
|
+
? ` Refunded ${response.refundedAmount} of ${response.refundedAssetPublicKey} via transfer ${response.refundTransferId}`
|
|
795
|
+
: "";
|
|
796
|
+
throw new FlashnetError(`${errorMessage}.${refundInfo}`, {
|
|
797
|
+
response: {
|
|
798
|
+
errorCode: hasRefund ? "FSAG-4202" : "UNKNOWN",
|
|
799
|
+
errorCategory: hasRefund ? "Business" : "System",
|
|
800
|
+
message: `${errorMessage}.${refundInfo}`,
|
|
801
|
+
requestId: "",
|
|
802
|
+
timestamp: new Date().toISOString(),
|
|
803
|
+
service: "amm-gateway",
|
|
804
|
+
severity: "Error",
|
|
805
|
+
},
|
|
806
|
+
httpStatus: 400,
|
|
807
|
+
transferIds: hasRefund ? [] : [initialTransferId],
|
|
808
|
+
lpIdentityPublicKey: firstPoolId,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
return response;
|
|
812
|
+
}, [initialTransferId], firstPoolId);
|
|
768
813
|
}
|
|
769
814
|
// ===== Liquidity Operations =====
|
|
770
815
|
/**
|
|
@@ -777,6 +822,9 @@ class FlashnetClient {
|
|
|
777
822
|
}
|
|
778
823
|
/**
|
|
779
824
|
* Add liquidity to a pool
|
|
825
|
+
*
|
|
826
|
+
* If adding liquidity fails with a clawbackable error, the SDK will automatically
|
|
827
|
+
* attempt to recover the transferred funds via clawback.
|
|
780
828
|
*/
|
|
781
829
|
async addLiquidity(params) {
|
|
782
830
|
await this.ensureInitialized();
|
|
@@ -806,44 +854,61 @@ class FlashnetClient {
|
|
|
806
854
|
amount: params.assetBAmount,
|
|
807
855
|
},
|
|
808
856
|
], "Insufficient balance for adding liquidity: ");
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
857
|
+
// Execute with auto-clawback on failure
|
|
858
|
+
return this.executeWithAutoClawback(async () => {
|
|
859
|
+
// Generate add liquidity intent
|
|
860
|
+
const nonce = generateNonce();
|
|
861
|
+
const intentMessage = generateAddLiquidityIntentMessage({
|
|
862
|
+
userPublicKey: this.publicKey,
|
|
863
|
+
lpIdentityPublicKey: params.poolId,
|
|
864
|
+
assetASparkTransferId: assetATransferId,
|
|
865
|
+
assetBSparkTransferId: assetBTransferId,
|
|
866
|
+
assetAAmount: params.assetAAmount.toString(),
|
|
867
|
+
assetBAmount: params.assetBAmount.toString(),
|
|
868
|
+
assetAMinAmountIn: params.assetAMinAmountIn.toString(),
|
|
869
|
+
assetBMinAmountIn: params.assetBMinAmountIn.toString(),
|
|
870
|
+
nonce,
|
|
871
|
+
});
|
|
872
|
+
// Sign intent
|
|
873
|
+
const messageHash = new Uint8Array(await crypto.subtle.digest("SHA-256", intentMessage));
|
|
874
|
+
const signature = await this._wallet.config.signer.signMessageWithIdentityKey(messageHash, true);
|
|
875
|
+
const request = {
|
|
876
|
+
userPublicKey: this.publicKey,
|
|
877
|
+
poolId: params.poolId,
|
|
878
|
+
assetASparkTransferId: assetATransferId,
|
|
879
|
+
assetBSparkTransferId: assetBTransferId,
|
|
880
|
+
assetAAmountToAdd: params.assetAAmount.toString(),
|
|
881
|
+
assetBAmountToAdd: params.assetBAmount.toString(),
|
|
882
|
+
assetAMinAmountIn: params.assetAMinAmountIn.toString(),
|
|
883
|
+
assetBMinAmountIn: params.assetBMinAmountIn.toString(),
|
|
884
|
+
nonce,
|
|
885
|
+
signature: getHexFromUint8Array(signature),
|
|
886
|
+
};
|
|
887
|
+
const response = await this.typedApi.addLiquidity(request);
|
|
888
|
+
// Check if the liquidity addition was accepted
|
|
889
|
+
if (!response.accepted) {
|
|
890
|
+
const errorMessage = response.error || "Add liquidity rejected by the AMM";
|
|
891
|
+
const hasRefund = !!(response.refund?.assetAAmount || response.refund?.assetBAmount);
|
|
892
|
+
const refundInfo = response.refund
|
|
893
|
+
? ` Refunds: Asset A: ${response.refund.assetAAmount || 0}, Asset B: ${response.refund.assetBAmount || 0}`
|
|
894
|
+
: "";
|
|
895
|
+
throw new FlashnetError(`${errorMessage}.${refundInfo}`, {
|
|
896
|
+
response: {
|
|
897
|
+
errorCode: hasRefund ? "FSAG-4203" : "UNKNOWN", // Phase error if refunded
|
|
898
|
+
errorCategory: hasRefund ? "Business" : "System",
|
|
899
|
+
message: `${errorMessage}.${refundInfo}`,
|
|
900
|
+
requestId: "",
|
|
901
|
+
timestamp: new Date().toISOString(),
|
|
902
|
+
service: "amm-gateway",
|
|
903
|
+
severity: "Error",
|
|
904
|
+
},
|
|
905
|
+
httpStatus: 400,
|
|
906
|
+
transferIds: hasRefund ? [] : [assetATransferId, assetBTransferId],
|
|
907
|
+
lpIdentityPublicKey: params.poolId,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return response;
|
|
911
|
+
}, [assetATransferId, assetBTransferId], params.poolId);
|
|
847
912
|
}
|
|
848
913
|
/**
|
|
849
914
|
* Simulate removing liquidity
|
|
@@ -1276,6 +1341,254 @@ class FlashnetClient {
|
|
|
1276
1341
|
await this.ensurePingOk();
|
|
1277
1342
|
return this.typedApi.listClawbackableTransfers(query);
|
|
1278
1343
|
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Attempt to clawback multiple transfers
|
|
1346
|
+
*
|
|
1347
|
+
* @param transferIds - Array of transfer IDs to clawback
|
|
1348
|
+
* @param lpIdentityPublicKey - The LP wallet public key
|
|
1349
|
+
* @returns Array of results for each clawback attempt
|
|
1350
|
+
*/
|
|
1351
|
+
async clawbackMultiple(transferIds, lpIdentityPublicKey) {
|
|
1352
|
+
const results = [];
|
|
1353
|
+
for (const transferId of transferIds) {
|
|
1354
|
+
try {
|
|
1355
|
+
const response = await this.clawback({
|
|
1356
|
+
sparkTransferId: transferId,
|
|
1357
|
+
lpIdentityPublicKey,
|
|
1358
|
+
});
|
|
1359
|
+
results.push({
|
|
1360
|
+
transferId,
|
|
1361
|
+
success: true,
|
|
1362
|
+
response,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
catch (err) {
|
|
1366
|
+
results.push({
|
|
1367
|
+
transferId,
|
|
1368
|
+
success: false,
|
|
1369
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return results;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Internal helper to execute an operation with automatic clawback on failure
|
|
1377
|
+
*
|
|
1378
|
+
* @param operation - The async operation to execute
|
|
1379
|
+
* @param transferIds - Transfer IDs that were sent and may need clawback
|
|
1380
|
+
* @param lpIdentityPublicKey - The LP wallet public key for clawback
|
|
1381
|
+
* @returns The result of the operation
|
|
1382
|
+
* @throws FlashnetError with typed clawbackSummary attached
|
|
1383
|
+
*/
|
|
1384
|
+
async executeWithAutoClawback(operation, transferIds, lpIdentityPublicKey) {
|
|
1385
|
+
try {
|
|
1386
|
+
return await operation();
|
|
1387
|
+
}
|
|
1388
|
+
catch (error) {
|
|
1389
|
+
// Convert to FlashnetError if not already
|
|
1390
|
+
const flashnetError = FlashnetError.fromUnknown(error, {
|
|
1391
|
+
transferIds,
|
|
1392
|
+
lpIdentityPublicKey,
|
|
1393
|
+
});
|
|
1394
|
+
// Check if we should attempt clawback
|
|
1395
|
+
if (flashnetError.shouldClawback() && transferIds.length > 0) {
|
|
1396
|
+
// Attempt to clawback all transfers
|
|
1397
|
+
const clawbackResults = await this.clawbackMultiple(transferIds, lpIdentityPublicKey);
|
|
1398
|
+
// Separate successful and failed clawbacks
|
|
1399
|
+
const successfulClawbacks = clawbackResults.filter((r) => r.success);
|
|
1400
|
+
const failedClawbacks = clawbackResults.filter((r) => !r.success);
|
|
1401
|
+
// Build typed clawback summary
|
|
1402
|
+
const clawbackSummary = {
|
|
1403
|
+
attempted: true,
|
|
1404
|
+
totalTransfers: transferIds.length,
|
|
1405
|
+
successCount: successfulClawbacks.length,
|
|
1406
|
+
failureCount: failedClawbacks.length,
|
|
1407
|
+
results: clawbackResults,
|
|
1408
|
+
recoveredTransferIds: successfulClawbacks.map((r) => r.transferId),
|
|
1409
|
+
unrecoveredTransferIds: failedClawbacks.map((r) => r.transferId),
|
|
1410
|
+
};
|
|
1411
|
+
// Create enhanced error message
|
|
1412
|
+
let enhancedMessage = flashnetError.message;
|
|
1413
|
+
if (successfulClawbacks.length > 0) {
|
|
1414
|
+
enhancedMessage += ` [Auto-clawback: ${successfulClawbacks.length}/${transferIds.length} transfers recovered]`;
|
|
1415
|
+
}
|
|
1416
|
+
if (failedClawbacks.length > 0) {
|
|
1417
|
+
const failedIds = failedClawbacks.map((r) => r.transferId).join(", ");
|
|
1418
|
+
enhancedMessage += ` [Clawback failed for: ${failedIds}]`;
|
|
1419
|
+
}
|
|
1420
|
+
// Determine remediation based on clawback results
|
|
1421
|
+
let remediation;
|
|
1422
|
+
if (clawbackSummary.failureCount === 0) {
|
|
1423
|
+
remediation =
|
|
1424
|
+
"Your funds have been automatically recovered. No action needed.";
|
|
1425
|
+
}
|
|
1426
|
+
else if (clawbackSummary.successCount > 0) {
|
|
1427
|
+
remediation = `${clawbackSummary.successCount} transfer(s) recovered. Manual clawback needed for remaining transfers.`;
|
|
1428
|
+
}
|
|
1429
|
+
else {
|
|
1430
|
+
remediation =
|
|
1431
|
+
flashnetError.remediation ??
|
|
1432
|
+
"Automatic recovery failed. Please initiate a manual clawback.";
|
|
1433
|
+
}
|
|
1434
|
+
// Throw new error with typed clawback summary
|
|
1435
|
+
const errorWithClawback = new FlashnetError(enhancedMessage, {
|
|
1436
|
+
response: {
|
|
1437
|
+
errorCode: flashnetError.errorCode,
|
|
1438
|
+
errorCategory: flashnetError.category,
|
|
1439
|
+
message: enhancedMessage,
|
|
1440
|
+
details: flashnetError.details,
|
|
1441
|
+
requestId: flashnetError.requestId,
|
|
1442
|
+
timestamp: flashnetError.timestamp,
|
|
1443
|
+
service: flashnetError.service,
|
|
1444
|
+
severity: flashnetError.severity,
|
|
1445
|
+
remediation,
|
|
1446
|
+
},
|
|
1447
|
+
httpStatus: flashnetError.httpStatus,
|
|
1448
|
+
transferIds: clawbackSummary.unrecoveredTransferIds,
|
|
1449
|
+
lpIdentityPublicKey,
|
|
1450
|
+
clawbackSummary,
|
|
1451
|
+
});
|
|
1452
|
+
throw errorWithClawback;
|
|
1453
|
+
}
|
|
1454
|
+
// Not a clawbackable error, just re-throw
|
|
1455
|
+
throw flashnetError;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// ===== Clawback Monitor =====
|
|
1459
|
+
/**
|
|
1460
|
+
* Start a background job that periodically polls for clawbackable transfers
|
|
1461
|
+
* and automatically claws them back.
|
|
1462
|
+
*
|
|
1463
|
+
* @param options - Monitor configuration options
|
|
1464
|
+
* @returns ClawbackMonitorHandle to control the monitor
|
|
1465
|
+
*
|
|
1466
|
+
* @example
|
|
1467
|
+
* ```typescript
|
|
1468
|
+
* const monitor = client.startClawbackMonitor({
|
|
1469
|
+
* intervalMs: 60000, // Poll every 60 seconds
|
|
1470
|
+
* onClawbackSuccess: (result) => console.log('Recovered:', result.transferId),
|
|
1471
|
+
* onClawbackError: (transferId, error) => console.error('Failed:', transferId, error),
|
|
1472
|
+
* });
|
|
1473
|
+
*
|
|
1474
|
+
* // Later, to stop:
|
|
1475
|
+
* monitor.stop();
|
|
1476
|
+
* ```
|
|
1477
|
+
*/
|
|
1478
|
+
startClawbackMonitor(options = {}) {
|
|
1479
|
+
const { intervalMs = 60000, // Default: 1 minute
|
|
1480
|
+
batchSize = 2, // Default: 2 clawbacks per batch (rate limit safe)
|
|
1481
|
+
batchDelayMs = 500, // Default: 500ms between batches
|
|
1482
|
+
maxTransfersPerPoll = 100, // Default: max 100 transfers per poll
|
|
1483
|
+
onClawbackSuccess, onClawbackError, onPollComplete, onPollError, } = options;
|
|
1484
|
+
let isRunning = true;
|
|
1485
|
+
let timeoutId = null;
|
|
1486
|
+
let currentPollPromise = null;
|
|
1487
|
+
const poll = async () => {
|
|
1488
|
+
const result = {
|
|
1489
|
+
transfersFound: 0,
|
|
1490
|
+
clawbacksAttempted: 0,
|
|
1491
|
+
clawbacksSucceeded: 0,
|
|
1492
|
+
clawbacksFailed: 0,
|
|
1493
|
+
results: [],
|
|
1494
|
+
};
|
|
1495
|
+
try {
|
|
1496
|
+
// Fetch clawbackable transfers
|
|
1497
|
+
const response = await this.listClawbackableTransfers({
|
|
1498
|
+
limit: maxTransfersPerPoll,
|
|
1499
|
+
});
|
|
1500
|
+
result.transfersFound = response.transfers.length;
|
|
1501
|
+
if (response.transfers.length === 0) {
|
|
1502
|
+
return result;
|
|
1503
|
+
}
|
|
1504
|
+
// Process in batches to respect rate limits
|
|
1505
|
+
for (let i = 0; i < response.transfers.length; i += batchSize) {
|
|
1506
|
+
if (!isRunning) {
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
const batch = response.transfers.slice(i, i + batchSize);
|
|
1510
|
+
// Process batch concurrently
|
|
1511
|
+
const batchResults = await Promise.all(batch.map(async (transfer) => {
|
|
1512
|
+
result.clawbacksAttempted++;
|
|
1513
|
+
try {
|
|
1514
|
+
const clawbackResponse = await this.clawback({
|
|
1515
|
+
sparkTransferId: transfer.id,
|
|
1516
|
+
lpIdentityPublicKey: transfer.lpIdentityPublicKey,
|
|
1517
|
+
});
|
|
1518
|
+
const attemptResult = {
|
|
1519
|
+
transferId: transfer.id,
|
|
1520
|
+
success: true,
|
|
1521
|
+
response: clawbackResponse,
|
|
1522
|
+
};
|
|
1523
|
+
result.clawbacksSucceeded++;
|
|
1524
|
+
onClawbackSuccess?.(attemptResult);
|
|
1525
|
+
return attemptResult;
|
|
1526
|
+
}
|
|
1527
|
+
catch (err) {
|
|
1528
|
+
const attemptResult = {
|
|
1529
|
+
transferId: transfer.id,
|
|
1530
|
+
success: false,
|
|
1531
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1532
|
+
};
|
|
1533
|
+
result.clawbacksFailed++;
|
|
1534
|
+
onClawbackError?.(transfer.id, err);
|
|
1535
|
+
return attemptResult;
|
|
1536
|
+
}
|
|
1537
|
+
}));
|
|
1538
|
+
result.results.push(...batchResults);
|
|
1539
|
+
// Wait between batches if there are more to process
|
|
1540
|
+
if (i + batchSize < response.transfers.length && isRunning) {
|
|
1541
|
+
await new Promise((resolve) => setTimeout(resolve, batchDelayMs));
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
catch (err) {
|
|
1546
|
+
onPollError?.(err);
|
|
1547
|
+
}
|
|
1548
|
+
return result;
|
|
1549
|
+
};
|
|
1550
|
+
const scheduleNextPoll = () => {
|
|
1551
|
+
if (!isRunning) {
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
timeoutId = setTimeout(async () => {
|
|
1555
|
+
if (!isRunning) {
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
currentPollPromise = (async () => {
|
|
1559
|
+
const result = await poll();
|
|
1560
|
+
onPollComplete?.(result);
|
|
1561
|
+
scheduleNextPoll();
|
|
1562
|
+
})();
|
|
1563
|
+
}, intervalMs);
|
|
1564
|
+
};
|
|
1565
|
+
// Start first poll immediately
|
|
1566
|
+
currentPollPromise = (async () => {
|
|
1567
|
+
const result = await poll();
|
|
1568
|
+
onPollComplete?.(result);
|
|
1569
|
+
scheduleNextPoll();
|
|
1570
|
+
})();
|
|
1571
|
+
return {
|
|
1572
|
+
isRunning: () => isRunning,
|
|
1573
|
+
stop: async () => {
|
|
1574
|
+
isRunning = false;
|
|
1575
|
+
if (timeoutId) {
|
|
1576
|
+
clearTimeout(timeoutId);
|
|
1577
|
+
timeoutId = null;
|
|
1578
|
+
}
|
|
1579
|
+
// Wait for current poll to complete
|
|
1580
|
+
if (currentPollPromise) {
|
|
1581
|
+
await currentPollPromise.catch(() => { });
|
|
1582
|
+
}
|
|
1583
|
+
},
|
|
1584
|
+
pollNow: async () => {
|
|
1585
|
+
if (!isRunning) {
|
|
1586
|
+
throw new Error("Monitor is stopped");
|
|
1587
|
+
}
|
|
1588
|
+
return poll();
|
|
1589
|
+
},
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1279
1592
|
// ===== Token Address Operations =====
|
|
1280
1593
|
/**
|
|
1281
1594
|
* Encode a token identifier into a human-readable token address using the client's Spark network
|
|
@@ -1473,6 +1786,489 @@ class FlashnetClient {
|
|
|
1473
1786
|
throw new Error(errorMessage);
|
|
1474
1787
|
}
|
|
1475
1788
|
}
|
|
1789
|
+
// ===== Lightning Payment with Token =====
|
|
1790
|
+
/**
|
|
1791
|
+
* Get a quote for paying a Lightning invoice with a token.
|
|
1792
|
+
* This calculates the optimal pool and token amount needed.
|
|
1793
|
+
*
|
|
1794
|
+
* @param invoice - BOLT11-encoded Lightning invoice
|
|
1795
|
+
* @param tokenAddress - Token identifier to use for payment
|
|
1796
|
+
* @param options - Optional configuration (slippage, integrator fees, etc.)
|
|
1797
|
+
* @returns Quote with pricing details
|
|
1798
|
+
* @throws Error if invoice amount or token amount is below Flashnet minimums
|
|
1799
|
+
*/
|
|
1800
|
+
async getPayLightningWithTokenQuote(invoice, tokenAddress, options) {
|
|
1801
|
+
await this.ensureInitialized();
|
|
1802
|
+
// Decode the invoice to get the amount
|
|
1803
|
+
const invoiceAmountSats = await this.decodeInvoiceAmount(invoice);
|
|
1804
|
+
if (!invoiceAmountSats || invoiceAmountSats <= 0) {
|
|
1805
|
+
throw new Error("Unable to decode invoice amount. Zero-amount invoices are not supported for token payments.");
|
|
1806
|
+
}
|
|
1807
|
+
// Get Lightning fee estimate
|
|
1808
|
+
const lightningFeeEstimate = await this.getLightningFeeEstimate(invoice);
|
|
1809
|
+
// Total BTC needed = invoice amount + lightning fee
|
|
1810
|
+
const baseBtcNeeded = BigInt(invoiceAmountSats) + BigInt(lightningFeeEstimate);
|
|
1811
|
+
// Round up to next multiple of 64 (2^6) to account for BTC variable fee bit masking.
|
|
1812
|
+
// The AMM zeroes the lowest 6 bits of BTC output, so we need to request enough
|
|
1813
|
+
// that after masking we still have the required amount.
|
|
1814
|
+
// Example: 5014 -> masked to 4992, but if we request 5056, masked stays 5056
|
|
1815
|
+
const BTC_VARIABLE_FEE_BITS = 6n;
|
|
1816
|
+
const BTC_VARIABLE_FEE_MASK = 1n << BTC_VARIABLE_FEE_BITS; // 64
|
|
1817
|
+
const totalBtcNeeded = ((baseBtcNeeded + BTC_VARIABLE_FEE_MASK - 1n) / BTC_VARIABLE_FEE_MASK) *
|
|
1818
|
+
BTC_VARIABLE_FEE_MASK;
|
|
1819
|
+
// Check Flashnet minimum amounts early to provide clear error messages
|
|
1820
|
+
const minAmounts = await this.getEnabledMinAmountsMap();
|
|
1821
|
+
// Check BTC minimum (output from swap)
|
|
1822
|
+
const btcMinAmount = minAmounts.get(BTC_ASSET_PUBKEY.toLowerCase());
|
|
1823
|
+
if (btcMinAmount && totalBtcNeeded < btcMinAmount) {
|
|
1824
|
+
throw new Error(`Invoice amount too small. Flashnet minimum BTC output is ${btcMinAmount} sats, ` +
|
|
1825
|
+
`but invoice + lightning fee totals only ${totalBtcNeeded} sats. ` +
|
|
1826
|
+
`Please use an invoice of at least ${btcMinAmount} sats.`);
|
|
1827
|
+
}
|
|
1828
|
+
// Find the best pool to swap token -> BTC
|
|
1829
|
+
const poolQuote = await this.findBestPoolForTokenToBtc(tokenAddress, totalBtcNeeded.toString(), options?.integratorFeeRateBps);
|
|
1830
|
+
// Check token minimum (input to swap)
|
|
1831
|
+
const tokenHex = this.toHexTokenIdentifier(tokenAddress).toLowerCase();
|
|
1832
|
+
const tokenMinAmount = minAmounts.get(tokenHex);
|
|
1833
|
+
if (tokenMinAmount &&
|
|
1834
|
+
BigInt(poolQuote.tokenAmountRequired) < tokenMinAmount) {
|
|
1835
|
+
throw new Error(`Token amount too small. Flashnet minimum input is ${tokenMinAmount} units, ` +
|
|
1836
|
+
`but calculated amount is only ${poolQuote.tokenAmountRequired} units. ` +
|
|
1837
|
+
`Please use a larger invoice amount.`);
|
|
1838
|
+
}
|
|
1839
|
+
// Calculate the BTC variable fee adjustment (how much extra we're requesting)
|
|
1840
|
+
const btcVariableFeeAdjustment = Number(totalBtcNeeded - baseBtcNeeded);
|
|
1841
|
+
return {
|
|
1842
|
+
poolId: poolQuote.poolId,
|
|
1843
|
+
tokenAddress: this.toHexTokenIdentifier(tokenAddress),
|
|
1844
|
+
tokenAmountRequired: poolQuote.tokenAmountRequired,
|
|
1845
|
+
btcAmountRequired: totalBtcNeeded.toString(),
|
|
1846
|
+
invoiceAmountSats: invoiceAmountSats,
|
|
1847
|
+
estimatedAmmFee: poolQuote.estimatedAmmFee,
|
|
1848
|
+
estimatedLightningFee: lightningFeeEstimate,
|
|
1849
|
+
btcVariableFeeAdjustment,
|
|
1850
|
+
executionPrice: poolQuote.executionPrice,
|
|
1851
|
+
priceImpactPct: poolQuote.priceImpactPct,
|
|
1852
|
+
tokenIsAssetA: poolQuote.tokenIsAssetA,
|
|
1853
|
+
poolReserves: poolQuote.poolReserves,
|
|
1854
|
+
warningMessage: poolQuote.warningMessage,
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Pay a Lightning invoice using a token.
|
|
1859
|
+
* This swaps the token to BTC on Flashnet and uses the BTC to pay the invoice.
|
|
1860
|
+
*
|
|
1861
|
+
* @param options - Payment options including invoice and token address
|
|
1862
|
+
* @returns Payment result with transaction details
|
|
1863
|
+
*/
|
|
1864
|
+
async payLightningWithToken(options) {
|
|
1865
|
+
await this.ensureInitialized();
|
|
1866
|
+
const { invoice, tokenAddress, maxSlippageBps = 100, // 1% default
|
|
1867
|
+
maxLightningFeeSats, preferSpark = true, integratorFeeRateBps, integratorPublicKey, transferTimeoutMs = 10000, } = options;
|
|
1868
|
+
try {
|
|
1869
|
+
// Step 1: Get a quote for the payment
|
|
1870
|
+
const quote = await this.getPayLightningWithTokenQuote(invoice, tokenAddress, {
|
|
1871
|
+
maxSlippageBps,
|
|
1872
|
+
integratorFeeRateBps,
|
|
1873
|
+
});
|
|
1874
|
+
// Step 2: Check balance
|
|
1875
|
+
await this.checkBalance({
|
|
1876
|
+
balancesToCheck: [
|
|
1877
|
+
{
|
|
1878
|
+
assetAddress: tokenAddress,
|
|
1879
|
+
amount: quote.tokenAmountRequired,
|
|
1880
|
+
},
|
|
1881
|
+
],
|
|
1882
|
+
errorPrefix: "Insufficient token balance for Lightning payment: ",
|
|
1883
|
+
});
|
|
1884
|
+
// Step 3: Get pool details
|
|
1885
|
+
const pool = await this.getPool(quote.poolId);
|
|
1886
|
+
// Step 4: Determine swap direction and execute
|
|
1887
|
+
const assetInAddress = quote.tokenIsAssetA
|
|
1888
|
+
? pool.assetAAddress
|
|
1889
|
+
: pool.assetBAddress;
|
|
1890
|
+
const assetOutAddress = quote.tokenIsAssetA
|
|
1891
|
+
? pool.assetBAddress
|
|
1892
|
+
: pool.assetAAddress;
|
|
1893
|
+
// Calculate min amount out with slippage protection
|
|
1894
|
+
const minBtcOut = this.calculateMinAmountOut(quote.btcAmountRequired, maxSlippageBps);
|
|
1895
|
+
// Execute the swap
|
|
1896
|
+
const swapResponse = await this.executeSwap({
|
|
1897
|
+
poolId: quote.poolId,
|
|
1898
|
+
assetInAddress,
|
|
1899
|
+
assetOutAddress,
|
|
1900
|
+
amountIn: quote.tokenAmountRequired,
|
|
1901
|
+
maxSlippageBps,
|
|
1902
|
+
minAmountOut: minBtcOut,
|
|
1903
|
+
integratorFeeRateBps,
|
|
1904
|
+
integratorPublicKey,
|
|
1905
|
+
});
|
|
1906
|
+
if (!swapResponse.accepted || !swapResponse.outboundTransferId) {
|
|
1907
|
+
return {
|
|
1908
|
+
success: false,
|
|
1909
|
+
poolId: quote.poolId,
|
|
1910
|
+
tokenAmountSpent: quote.tokenAmountRequired,
|
|
1911
|
+
btcAmountReceived: "0",
|
|
1912
|
+
swapTransferId: swapResponse.outboundTransferId || "",
|
|
1913
|
+
ammFeePaid: quote.estimatedAmmFee,
|
|
1914
|
+
error: swapResponse.error || "Swap was not accepted",
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
// Step 5: Wait for the transfer to complete
|
|
1918
|
+
const transferComplete = await this.waitForTransferCompletion(swapResponse.outboundTransferId, transferTimeoutMs);
|
|
1919
|
+
if (!transferComplete) {
|
|
1920
|
+
return {
|
|
1921
|
+
success: false,
|
|
1922
|
+
poolId: quote.poolId,
|
|
1923
|
+
tokenAmountSpent: quote.tokenAmountRequired,
|
|
1924
|
+
btcAmountReceived: swapResponse.amountOut || "0",
|
|
1925
|
+
swapTransferId: swapResponse.outboundTransferId,
|
|
1926
|
+
ammFeePaid: quote.estimatedAmmFee,
|
|
1927
|
+
error: "Transfer did not complete within timeout",
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
// Step 6: Calculate Lightning fee limit
|
|
1931
|
+
const invoiceAmountSats = await this.decodeInvoiceAmount(invoice);
|
|
1932
|
+
const effectiveMaxLightningFee = maxLightningFeeSats ??
|
|
1933
|
+
Math.max(5, Math.ceil(invoiceAmountSats * 0.0017)); // 17 bps or 5 sats minimum
|
|
1934
|
+
// Step 7: Pay the Lightning invoice
|
|
1935
|
+
const lightningPayment = await this._wallet.payLightningInvoice({
|
|
1936
|
+
invoice,
|
|
1937
|
+
maxFeeSats: effectiveMaxLightningFee,
|
|
1938
|
+
preferSpark,
|
|
1939
|
+
});
|
|
1940
|
+
return {
|
|
1941
|
+
success: true,
|
|
1942
|
+
poolId: quote.poolId,
|
|
1943
|
+
tokenAmountSpent: quote.tokenAmountRequired,
|
|
1944
|
+
btcAmountReceived: swapResponse.amountOut || quote.btcAmountRequired,
|
|
1945
|
+
swapTransferId: swapResponse.outboundTransferId,
|
|
1946
|
+
lightningPaymentId: lightningPayment.id,
|
|
1947
|
+
ammFeePaid: quote.estimatedAmmFee,
|
|
1948
|
+
lightningFeePaid: effectiveMaxLightningFee,
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
catch (error) {
|
|
1952
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1953
|
+
return {
|
|
1954
|
+
success: false,
|
|
1955
|
+
poolId: "",
|
|
1956
|
+
tokenAmountSpent: "0",
|
|
1957
|
+
btcAmountReceived: "0",
|
|
1958
|
+
swapTransferId: "",
|
|
1959
|
+
ammFeePaid: "0",
|
|
1960
|
+
error: errorMessage,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Find the best pool for swapping a token to BTC
|
|
1966
|
+
* @private
|
|
1967
|
+
*/
|
|
1968
|
+
async findBestPoolForTokenToBtc(tokenAddress, btcAmountNeeded, integratorFeeRateBps) {
|
|
1969
|
+
const tokenHex = this.toHexTokenIdentifier(tokenAddress);
|
|
1970
|
+
const btcHex = BTC_ASSET_PUBKEY;
|
|
1971
|
+
// Find all pools that have this token paired with BTC
|
|
1972
|
+
const poolsWithTokenAsA = await this.listPools({
|
|
1973
|
+
assetAAddress: tokenHex,
|
|
1974
|
+
assetBAddress: btcHex,
|
|
1975
|
+
});
|
|
1976
|
+
const poolsWithTokenAsB = await this.listPools({
|
|
1977
|
+
assetAAddress: btcHex,
|
|
1978
|
+
assetBAddress: tokenHex,
|
|
1979
|
+
});
|
|
1980
|
+
const allPools = [
|
|
1981
|
+
...poolsWithTokenAsA.pools.map((p) => ({ ...p, tokenIsAssetA: true })),
|
|
1982
|
+
...poolsWithTokenAsB.pools.map((p) => ({ ...p, tokenIsAssetA: false })),
|
|
1983
|
+
];
|
|
1984
|
+
if (allPools.length === 0) {
|
|
1985
|
+
throw new Error(`No liquidity pool found for token ${tokenAddress} paired with BTC`);
|
|
1986
|
+
}
|
|
1987
|
+
// Find the best pool (lowest token cost for the required BTC)
|
|
1988
|
+
let bestPool = null;
|
|
1989
|
+
let bestTokenAmount = BigInt(Number.MAX_SAFE_INTEGER);
|
|
1990
|
+
let bestSimulation = null;
|
|
1991
|
+
for (const pool of allPools) {
|
|
1992
|
+
try {
|
|
1993
|
+
// Get pool details for reserves
|
|
1994
|
+
const poolDetails = await this.getPool(pool.lpPublicKey);
|
|
1995
|
+
// Calculate the token amount needed using AMM math
|
|
1996
|
+
const calculation = this.calculateTokenAmountForBtcOutput(btcAmountNeeded, poolDetails.assetAReserve, poolDetails.assetBReserve, poolDetails.lpFeeBps, poolDetails.hostFeeBps, pool.tokenIsAssetA, integratorFeeRateBps);
|
|
1997
|
+
const tokenAmount = BigInt(calculation.amountIn);
|
|
1998
|
+
// Check if this is better than our current best
|
|
1999
|
+
if (tokenAmount < bestTokenAmount) {
|
|
2000
|
+
// Verify with simulation
|
|
2001
|
+
const simulation = await this.simulateSwap({
|
|
2002
|
+
poolId: pool.lpPublicKey,
|
|
2003
|
+
assetInAddress: pool.tokenIsAssetA
|
|
2004
|
+
? poolDetails.assetAAddress
|
|
2005
|
+
: poolDetails.assetBAddress,
|
|
2006
|
+
assetOutAddress: pool.tokenIsAssetA
|
|
2007
|
+
? poolDetails.assetBAddress
|
|
2008
|
+
: poolDetails.assetAAddress,
|
|
2009
|
+
amountIn: calculation.amountIn,
|
|
2010
|
+
integratorBps: integratorFeeRateBps,
|
|
2011
|
+
});
|
|
2012
|
+
// Verify the output is sufficient
|
|
2013
|
+
if (BigInt(simulation.amountOut) >= BigInt(btcAmountNeeded)) {
|
|
2014
|
+
bestPool = pool;
|
|
2015
|
+
bestTokenAmount = tokenAmount;
|
|
2016
|
+
bestSimulation = {
|
|
2017
|
+
amountIn: calculation.amountIn,
|
|
2018
|
+
fee: calculation.totalFee,
|
|
2019
|
+
executionPrice: simulation.executionPrice || "0",
|
|
2020
|
+
priceImpactPct: simulation.priceImpactPct || "0",
|
|
2021
|
+
warningMessage: simulation.warningMessage,
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
catch { }
|
|
2027
|
+
}
|
|
2028
|
+
if (!bestPool || !bestSimulation) {
|
|
2029
|
+
throw new Error(`No pool has sufficient liquidity for ${btcAmountNeeded} sats`);
|
|
2030
|
+
}
|
|
2031
|
+
const poolDetails = await this.getPool(bestPool.lpPublicKey);
|
|
2032
|
+
return {
|
|
2033
|
+
poolId: bestPool.lpPublicKey,
|
|
2034
|
+
tokenAmountRequired: bestSimulation.amountIn,
|
|
2035
|
+
estimatedAmmFee: bestSimulation.fee,
|
|
2036
|
+
executionPrice: bestSimulation.executionPrice,
|
|
2037
|
+
priceImpactPct: bestSimulation.priceImpactPct,
|
|
2038
|
+
tokenIsAssetA: bestPool.tokenIsAssetA,
|
|
2039
|
+
poolReserves: {
|
|
2040
|
+
assetAReserve: poolDetails.assetAReserve,
|
|
2041
|
+
assetBReserve: poolDetails.assetBReserve,
|
|
2042
|
+
},
|
|
2043
|
+
warningMessage: bestSimulation.warningMessage,
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Calculate the token amount needed to get a specific BTC output.
|
|
2048
|
+
* Implements the AMM fee-inclusive model.
|
|
2049
|
+
* @private
|
|
2050
|
+
*/
|
|
2051
|
+
calculateTokenAmountForBtcOutput(btcAmountOut, reserveA, reserveB, lpFeeBps, hostFeeBps, tokenIsAssetA, integratorFeeBps) {
|
|
2052
|
+
const amountOut = BigInt(btcAmountOut);
|
|
2053
|
+
const resA = BigInt(reserveA);
|
|
2054
|
+
const resB = BigInt(reserveB);
|
|
2055
|
+
const totalFeeBps = lpFeeBps + hostFeeBps + (integratorFeeBps || 0);
|
|
2056
|
+
const feeRate = Number(totalFeeBps) / 10000; // Convert bps to decimal
|
|
2057
|
+
// Token is the input asset
|
|
2058
|
+
// BTC is the output asset
|
|
2059
|
+
if (tokenIsAssetA) {
|
|
2060
|
+
// Token is asset A, BTC is asset B
|
|
2061
|
+
// A → B swap: we want BTC out (asset B)
|
|
2062
|
+
// reserve_in = reserveA (token), reserve_out = reserveB (BTC)
|
|
2063
|
+
// Constant product formula for amount_in given amount_out:
|
|
2064
|
+
// amount_in_effective = (reserve_in * amount_out) / (reserve_out - amount_out)
|
|
2065
|
+
const reserveIn = resA;
|
|
2066
|
+
const reserveOut = resB;
|
|
2067
|
+
if (amountOut >= reserveOut) {
|
|
2068
|
+
throw new Error("Insufficient liquidity: requested BTC amount exceeds reserve");
|
|
2069
|
+
}
|
|
2070
|
+
// Calculate effective amount in (before fees)
|
|
2071
|
+
const amountInEffective = (reserveIn * amountOut) / (reserveOut - amountOut) + 1n; // +1 for rounding up
|
|
2072
|
+
// A→B swap: LP fee deducted from input A, integrator fee from output B
|
|
2073
|
+
// amount_in = amount_in_effective * (1 + lp_fee_rate)
|
|
2074
|
+
// Then integrator fee is deducted from output, so we need slightly more input
|
|
2075
|
+
const lpFeeRate = Number(lpFeeBps) / 10000;
|
|
2076
|
+
const integratorFeeRate = Number(integratorFeeBps || 0) / 10000;
|
|
2077
|
+
// Account for LP fee on input
|
|
2078
|
+
const amountInWithLpFee = BigInt(Math.ceil(Number(amountInEffective) * (1 + lpFeeRate)));
|
|
2079
|
+
// Account for integrator fee on output (need more input to get same output after fee)
|
|
2080
|
+
const amountIn = integratorFeeRate > 0
|
|
2081
|
+
? BigInt(Math.ceil(Number(amountInWithLpFee) * (1 + integratorFeeRate)))
|
|
2082
|
+
: amountInWithLpFee;
|
|
2083
|
+
const totalFee = amountIn - amountInEffective;
|
|
2084
|
+
return {
|
|
2085
|
+
amountIn: amountIn.toString(),
|
|
2086
|
+
totalFee: totalFee.toString(),
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
else {
|
|
2090
|
+
// Token is asset B, BTC is asset A
|
|
2091
|
+
// B → A swap: we want BTC out (asset A)
|
|
2092
|
+
// reserve_in = reserveB (token), reserve_out = reserveA (BTC)
|
|
2093
|
+
const reserveIn = resB;
|
|
2094
|
+
const reserveOut = resA;
|
|
2095
|
+
if (amountOut >= reserveOut) {
|
|
2096
|
+
throw new Error("Insufficient liquidity: requested BTC amount exceeds reserve");
|
|
2097
|
+
}
|
|
2098
|
+
// Calculate effective amount in (before fees)
|
|
2099
|
+
const amountInEffective = (reserveIn * amountOut) / (reserveOut - amountOut) + 1n; // +1 for rounding up
|
|
2100
|
+
// B→A swap: ALL fees (LP + integrator) deducted from input B
|
|
2101
|
+
// amount_in = amount_in_effective * (1 + total_fee_rate)
|
|
2102
|
+
const amountIn = BigInt(Math.ceil(Number(amountInEffective) * (1 + feeRate)));
|
|
2103
|
+
// Fee calculation: fee = amount_in * fee_rate / (1 + fee_rate)
|
|
2104
|
+
const totalFee = BigInt(Math.ceil((Number(amountIn) * feeRate) / (1 + feeRate)));
|
|
2105
|
+
return {
|
|
2106
|
+
amountIn: amountIn.toString(),
|
|
2107
|
+
totalFee: totalFee.toString(),
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* Calculate minimum amount out with slippage protection
|
|
2113
|
+
* @private
|
|
2114
|
+
*/
|
|
2115
|
+
calculateMinAmountOut(expectedAmount, slippageBps) {
|
|
2116
|
+
const amount = BigInt(expectedAmount);
|
|
2117
|
+
const slippageFactor = BigInt(10000 - slippageBps);
|
|
2118
|
+
const minAmount = (amount * slippageFactor) / 10000n;
|
|
2119
|
+
return minAmount.toString();
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Wait for a transfer to be claimed using wallet events.
|
|
2123
|
+
* This is more efficient than polling as it uses the wallet's event stream.
|
|
2124
|
+
* @private
|
|
2125
|
+
*/
|
|
2126
|
+
async waitForTransferCompletion(transferId, timeoutMs) {
|
|
2127
|
+
return new Promise((resolve) => {
|
|
2128
|
+
const timeout = setTimeout(() => {
|
|
2129
|
+
// Remove listener on timeout
|
|
2130
|
+
try {
|
|
2131
|
+
this._wallet.removeListener?.("transfer:claimed", handler);
|
|
2132
|
+
}
|
|
2133
|
+
catch {
|
|
2134
|
+
// Ignore if removeListener doesn't exist
|
|
2135
|
+
}
|
|
2136
|
+
resolve(false);
|
|
2137
|
+
}, timeoutMs);
|
|
2138
|
+
const handler = (claimedTransferId, _balance) => {
|
|
2139
|
+
if (claimedTransferId === transferId) {
|
|
2140
|
+
clearTimeout(timeout);
|
|
2141
|
+
try {
|
|
2142
|
+
this._wallet.removeListener?.("transfer:claimed", handler);
|
|
2143
|
+
}
|
|
2144
|
+
catch {
|
|
2145
|
+
// Ignore if removeListener doesn't exist
|
|
2146
|
+
}
|
|
2147
|
+
resolve(true);
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
// Subscribe to transfer claimed events
|
|
2151
|
+
// The wallet's RPC stream will automatically claim incoming transfers
|
|
2152
|
+
try {
|
|
2153
|
+
this._wallet.on?.("transfer:claimed", handler);
|
|
2154
|
+
}
|
|
2155
|
+
catch {
|
|
2156
|
+
// If event subscription fails, fall back to polling
|
|
2157
|
+
clearTimeout(timeout);
|
|
2158
|
+
this.pollForTransferCompletion(transferId, timeoutMs).then(resolve);
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Fallback polling method for transfer completion
|
|
2164
|
+
* @private
|
|
2165
|
+
*/
|
|
2166
|
+
async pollForTransferCompletion(transferId, timeoutMs) {
|
|
2167
|
+
const startTime = Date.now();
|
|
2168
|
+
const pollIntervalMs = 500;
|
|
2169
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
2170
|
+
try {
|
|
2171
|
+
const transfer = await this._wallet.getTransfer(transferId);
|
|
2172
|
+
if (transfer) {
|
|
2173
|
+
if (transfer.status === "TRANSFER_STATUS_COMPLETED") {
|
|
2174
|
+
return true;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
catch {
|
|
2179
|
+
// Ignore errors and continue polling
|
|
2180
|
+
}
|
|
2181
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
2182
|
+
}
|
|
2183
|
+
return false;
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Get Lightning fee estimate for an invoice
|
|
2187
|
+
* @private
|
|
2188
|
+
*/
|
|
2189
|
+
async getLightningFeeEstimate(invoice) {
|
|
2190
|
+
try {
|
|
2191
|
+
const feeEstimate = await this._wallet.getLightningSendFeeEstimate({
|
|
2192
|
+
encodedInvoice: invoice,
|
|
2193
|
+
});
|
|
2194
|
+
// The fee estimate might be returned as a number or an object
|
|
2195
|
+
if (typeof feeEstimate === "number") {
|
|
2196
|
+
return feeEstimate;
|
|
2197
|
+
}
|
|
2198
|
+
if (feeEstimate?.fee || feeEstimate?.feeEstimate) {
|
|
2199
|
+
return Number(feeEstimate.fee || feeEstimate.feeEstimate);
|
|
2200
|
+
}
|
|
2201
|
+
// Fallback to invoice amount-based estimate
|
|
2202
|
+
const invoiceAmount = await this.decodeInvoiceAmount(invoice);
|
|
2203
|
+
return Math.max(5, Math.ceil(invoiceAmount * 0.0017)); // 17 bps or 5 sats minimum
|
|
2204
|
+
}
|
|
2205
|
+
catch {
|
|
2206
|
+
// Fallback to invoice amount-based estimate
|
|
2207
|
+
const invoiceAmount = await this.decodeInvoiceAmount(invoice);
|
|
2208
|
+
return Math.max(5, Math.ceil(invoiceAmount * 0.0017));
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Decode the amount from a Lightning invoice (in sats)
|
|
2213
|
+
* @private
|
|
2214
|
+
*/
|
|
2215
|
+
async decodeInvoiceAmount(invoice) {
|
|
2216
|
+
// Extract amount from BOLT11 invoice
|
|
2217
|
+
// Format: ln[network][amount][multiplier]...
|
|
2218
|
+
// Amount multipliers: m = milli (0.001), u = micro (0.000001), n = nano, p = pico
|
|
2219
|
+
const lowerInvoice = invoice.toLowerCase();
|
|
2220
|
+
// Find where the amount starts (after network prefix)
|
|
2221
|
+
let amountStart = 0;
|
|
2222
|
+
if (lowerInvoice.startsWith("lnbc")) {
|
|
2223
|
+
amountStart = 4;
|
|
2224
|
+
}
|
|
2225
|
+
else if (lowerInvoice.startsWith("lntb")) {
|
|
2226
|
+
amountStart = 4;
|
|
2227
|
+
}
|
|
2228
|
+
else if (lowerInvoice.startsWith("lnbcrt")) {
|
|
2229
|
+
amountStart = 6;
|
|
2230
|
+
}
|
|
2231
|
+
else if (lowerInvoice.startsWith("lntbs")) {
|
|
2232
|
+
amountStart = 5;
|
|
2233
|
+
}
|
|
2234
|
+
else {
|
|
2235
|
+
// Unknown format, try to find amount
|
|
2236
|
+
const match = lowerInvoice.match(/^ln[a-z]+/);
|
|
2237
|
+
if (match) {
|
|
2238
|
+
amountStart = match[0].length;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
// Extract amount and multiplier
|
|
2242
|
+
const afterPrefix = lowerInvoice.substring(amountStart);
|
|
2243
|
+
const amountMatch = afterPrefix.match(/^(\d+)([munp]?)/);
|
|
2244
|
+
if (!amountMatch || !amountMatch[1]) {
|
|
2245
|
+
return 0; // Zero-amount invoice
|
|
2246
|
+
}
|
|
2247
|
+
const amount = parseInt(amountMatch[1], 10);
|
|
2248
|
+
const multiplier = amountMatch[2] ?? "";
|
|
2249
|
+
// Convert to satoshis (1 BTC = 100,000,000 sats)
|
|
2250
|
+
// Invoice amounts are in BTC by default
|
|
2251
|
+
let btcAmount;
|
|
2252
|
+
switch (multiplier) {
|
|
2253
|
+
case "m": // milli-BTC (0.001 BTC)
|
|
2254
|
+
btcAmount = amount * 0.001;
|
|
2255
|
+
break;
|
|
2256
|
+
case "u": // micro-BTC (0.000001 BTC)
|
|
2257
|
+
btcAmount = amount * 0.000001;
|
|
2258
|
+
break;
|
|
2259
|
+
case "n": // nano-BTC (0.000000001 BTC)
|
|
2260
|
+
btcAmount = amount * 0.000000001;
|
|
2261
|
+
break;
|
|
2262
|
+
case "p": // pico-BTC (0.000000000001 BTC)
|
|
2263
|
+
btcAmount = amount * 0.000000000001;
|
|
2264
|
+
break;
|
|
2265
|
+
default: // BTC
|
|
2266
|
+
btcAmount = amount;
|
|
2267
|
+
break;
|
|
2268
|
+
}
|
|
2269
|
+
// Convert BTC to sats
|
|
2270
|
+
return Math.round(btcAmount * 100000000);
|
|
2271
|
+
}
|
|
1476
2272
|
/**
|
|
1477
2273
|
* Clean up wallet connections
|
|
1478
2274
|
*/
|