Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/integration/blockchain/juice/juice-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
57 changes: 47 additions & 10 deletions src/integration/blockchain/spark/spark-client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,19 +43,16 @@ export interface SparkFeeEstimate {
}

export class SparkClient extends BlockchainClient {
private readonly wallet: AsyncField<SparkWallet>;
private readonly logger = new DfxLogger(SparkClient);

private wallet: AsyncField<SparkWallet>;
private readonly cachedAddress: AsyncField<string>;
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);
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -128,6 +128,43 @@ export class SparkClient extends BlockchainClient {
);
}

// --- WALLET INITIALIZATION --- //

private initializeWallet(): Promise<SparkWallet> {
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<void>((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<SparkWallet> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<boolean> {
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<string, unknown>): 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<Asset> {
Expand All @@ -35,4 +77,56 @@ export class JuiceAdapter extends FrankencoinBasedAdapter {
blockchain: Blockchain.CITREA,
});
}

private async bridgeIn(order: LiquidityManagementOrder): Promise<CorrelationId> {
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<CorrelationId> {
// TODO: Implement bridge out (JUSD → USD stablecoins)
throw new OrderNotProcessableException('Bridge out is not yet implemented');
}

private validateBridgeInParams(params: Record<string, unknown>): boolean {
try {
this.parseBridgeInParams(params);
return true;
} catch {
return false;
}
}

private parseBridgeInParams(params: Record<string, unknown>): { asset: string } {
const asset = params.asset as string;

if (!asset) {
throw new Error('asset parameter is required for bridge-in command');
}

return { asset };
}
}
Loading