diff --git a/src/integration/blockchain/juice/juice-client.ts b/src/integration/blockchain/juice/juice-client.ts index 05e5dc6316..a14b4e1056 100644 --- a/src/integration/blockchain/juice/juice-client.ts +++ b/src/integration/blockchain/juice/juice-client.ts @@ -152,6 +152,7 @@ export class JuiceClient { case 'USDC': return this.getBridgeUSDCContract(); case 'USDT': + case 'USDT.e': return this.getBridgeUSDTContract(); case 'CTUSD': return this.getBridgeCTUSDContract(); diff --git a/src/integration/blockchain/spark/spark-client.ts b/src/integration/blockchain/spark/spark-client.ts index 73f29e4b12..522bf20cd6 100644 --- a/src/integration/blockchain/spark/spark-client.ts +++ b/src/integration/blockchain/spark/spark-client.ts @@ -1,6 +1,7 @@ import { SparkWallet } from '@buildonspark/spark-sdk'; import { Currency } from '@uniswap/sdk-core'; import { GetConfig } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AsyncField } from 'src/shared/utils/async-field'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; import { BlockchainClient } from '../shared/util/blockchain-client'; @@ -42,19 +43,16 @@ export interface SparkFeeEstimate { } export class SparkClient extends BlockchainClient { - private readonly wallet: AsyncField; + private readonly logger = new DfxLogger(SparkClient); + + private wallet: AsyncField; private readonly cachedAddress: AsyncField; + private reconnectAttempt = 0; constructor() { super(); - this.wallet = new AsyncField(() => - SparkWallet.initialize({ - mnemonicOrSeed: GetConfig().blockchain.spark.sparkWalletSeed, - accountNumber: 0, - options: { network: 'MAINNET' }, - }).then(({ wallet }) => this.syncLeaves(wallet)), - ); + this.wallet = new AsyncField(() => this.initializeWallet(), true); this.cachedAddress = new AsyncField(() => this.wallet.then((w) => w.getSparkAddress()), true); } @@ -90,8 +88,10 @@ export class SparkClient extends BlockchainClient { throw new Error(`Transaction ${txId} not found`); } - // SPARK uses final confirmation - either confirmed (1) or not (0) - const isConfirmed = transfer.status === 'TRANSFER_STATUS_COMPLETED'; + // Outgoing: complete once sender key is tweaked (funds left our wallet) + // Incoming: complete once receiver has claimed + const isConfirmed = + transfer.status === 'TRANSFER_STATUS_SENDER_KEY_TWEAKED' || transfer.status === 'TRANSFER_STATUS_COMPLETED'; return { txid: transfer.id, @@ -128,6 +128,43 @@ export class SparkClient extends BlockchainClient { ); } + // --- WALLET INITIALIZATION --- // + + private initializeWallet(): Promise { + return SparkWallet.initialize({ + mnemonicOrSeed: GetConfig().blockchain.spark.sparkWalletSeed, + accountNumber: 0, + options: { network: 'MAINNET' }, + }).then(({ wallet }) => { + wallet.on('stream:disconnected', () => this.reconnectWallet()); + return this.syncLeaves(wallet); + }); + } + + private reconnectWallet(): void { + const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 60_000); + this.reconnectAttempt++; + + this.logger.warn(`Spark stream disconnected, reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempt})`); + + this.wallet = new AsyncField( + () => + new Promise((resolve) => setTimeout(resolve, delay)) + .then(() => this.initializeWallet()) + .then((wallet) => { + this.reconnectAttempt = 0; + this.logger.info('Spark wallet reconnected successfully'); + return wallet; + }) + .catch((e: Error) => { + this.logger.error('Spark wallet reconnect failed', e); + this.reconnectWallet(); + throw e; + }), + true, + ); + } + // --- SYNC METHODS --- // private async syncLeaves(wallet: SparkWallet): Promise { diff --git a/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts index df12492789..e5f5286262 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/juice.adapter.ts @@ -3,10 +3,18 @@ import { JuiceService } from 'src/integration/blockchain/juice/juice.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity'; import { LiquidityManagementSystem } from '../../enums'; +import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; +import { CorrelationId } from '../../interfaces'; import { LiquidityManagementBalanceService } from '../../services/liquidity-management-balance.service'; import { FrankencoinBasedAdapter, FrankencoinBasedAdapterCommands } from './base/frankencoin-based.adapter'; +export enum JuiceAdapterCommands { + BRIDGE_IN = 'bridge-in', + BRIDGE_OUT = 'bridge-out', +} + @Injectable() export class JuiceAdapter extends FrankencoinBasedAdapter { constructor( @@ -18,6 +26,40 @@ export class JuiceAdapter extends FrankencoinBasedAdapter { // Juice doesn't have a wrapper contract this.commands.delete(FrankencoinBasedAdapterCommands.WRAP); + + this.commands.set(JuiceAdapterCommands.BRIDGE_IN, this.bridgeIn.bind(this)); + this.commands.set(JuiceAdapterCommands.BRIDGE_OUT, this.bridgeOut.bind(this)); + } + + async checkCompletion(order: LiquidityManagementOrder): Promise { + if ( + order.action.command === JuiceAdapterCommands.BRIDGE_IN || + order.action.command === JuiceAdapterCommands.BRIDGE_OUT + ) { + const client = this.juiceService.getEvmClient(); + const txHash = order.correlationId; + + try { + return await client.isTxComplete(txHash); + } catch { + return false; + } + } + + return super.checkCompletion(order); + } + + validateParams(command: string, params: Record): boolean { + switch (command) { + case JuiceAdapterCommands.BRIDGE_IN: + return this.validateBridgeInParams(params); + + case JuiceAdapterCommands.BRIDGE_OUT: + return false; // not yet implemented + + default: + return super.validateParams(command, params); + } } async getStableToken(): Promise { @@ -35,4 +77,56 @@ export class JuiceAdapter extends FrankencoinBasedAdapter { blockchain: Blockchain.CITREA, }); } + + private async bridgeIn(order: LiquidityManagementOrder): Promise { + const { asset } = this.parseBridgeInParams(order.action.paramMap); + + const jusdAsset = await this.getStableToken(); + const sourceAsset = await this.assetService.getAssetByQuery({ + name: asset, + type: AssetType.TOKEN, + blockchain: jusdAsset.blockchain, + }); + + const sourceBalance = await this.juiceService.getEvmClient().getTokenBalance(sourceAsset); + + if (sourceBalance < order.minAmount) { + throw new OrderNotProcessableException( + `Not enough ${sourceAsset.name} liquidity (balance: ${sourceBalance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); + } + + const amount = Math.min(order.maxAmount, sourceBalance); + + order.inputAmount = amount; + order.inputAsset = sourceAsset.name; + order.outputAmount = amount; + order.outputAsset = jusdAsset.name; + + return this.juiceService.bridgeToJusd(sourceAsset, amount); + } + + private async bridgeOut(_order: LiquidityManagementOrder): Promise { + // TODO: Implement bridge out (JUSD → USD stablecoins) + throw new OrderNotProcessableException('Bridge out is not yet implemented'); + } + + private validateBridgeInParams(params: Record): boolean { + try { + this.parseBridgeInParams(params); + return true; + } catch { + return false; + } + } + + private parseBridgeInParams(params: Record): { asset: string } { + const asset = params.asset as string; + + if (!asset) { + throw new Error('asset parameter is required for bridge-in command'); + } + + return { asset }; + } }