Skip to content

Commit 0b74c75

Browse files
authored
Add public Boltz balance endpoint (#125)
* Add public Boltz balance endpoint - Add GET /v1/boltz-balance endpoint for public wallet balance monitoring - Create BoltzModule with controller, service, DTOs, entity and repository - Add asset_boltz table migration with environment-specific token addresses - Support BTC, Lightning, and EVM token balances (USDC, USDT, WBTC, JUSD, WBTCe) * Refactor Boltz balance endpoint to return flat array - Rename endpoint from /boltz-balance to /boltz/balance - Change response from structured object to flat BalanceDto array - Rename config key from boltzBalance to boltz - Update migration to schema-only (remove seed data) - Add Direction enum for Lightning balance direction * Rename walletAddress to evmWalletAddress in boltz config * Changes after review
1 parent 72c8a82 commit 0b74c75

File tree

10 files changed

+335
-4
lines changed

10 files changed

+335
-4
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
3+
* @typedef {import('typeorm').QueryRunner} QueryRunner
4+
*/
5+
6+
/**
7+
* @class
8+
* @implements {MigrationInterface}
9+
*/
10+
module.exports = class CreateAssetBoltz1770192795341 {
11+
name = 'CreateAssetBoltz1770192795341'
12+
13+
/**
14+
* @param {QueryRunner} queryRunner
15+
*/
16+
async up(queryRunner) {
17+
await queryRunner.query(`CREATE TABLE "asset_boltz" ("id" int NOT NULL IDENTITY(1,1), "created" datetime2 NOT NULL CONSTRAINT "DF_f8eee661bfc0ba34f0f18960a35" DEFAULT getdate(), "updated" datetime2 NOT NULL CONSTRAINT "DF_bf77b89d4b7d1e854206229afea" DEFAULT getdate(), "name" nvarchar(255) NOT NULL, "blockchain" nvarchar(255) NOT NULL, "address" nvarchar(255) NOT NULL, "decimals" int NOT NULL, CONSTRAINT "PK_ff1e802368b299c2f1a88ec1af6" PRIMARY KEY ("id"))`);
18+
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a5500b6a3fca6dd68cc08ea756" ON "asset_boltz" ("name", "blockchain") `);
19+
}
20+
21+
/**
22+
* @param {QueryRunner} queryRunner
23+
*/
24+
async down(queryRunner) {
25+
await queryRunner.query(`DROP INDEX "IDX_a5500b6a3fca6dd68cc08ea756" ON "asset_boltz"`);
26+
await queryRunner.query(`DROP TABLE "asset_boltz"`);
27+
}
28+
}

src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ export class Configuration {
211211
claimApiUrl: process.env.SWAP_CLAIM_API_URL,
212212
};
213213

214+
boltz = {
215+
evmWalletAddress: process.env.BOLTZ_WALLET_ADDRESS ?? '',
216+
};
217+
214218
// --- GETTERS --- //
215219
get baseUrl(): string {
216220
return this.environment === Environment.LOC

src/subdomains/alchemy/services/alchemy.service.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ export class AlchemyService {
1515
}
1616

1717
async getTokenBalances(chainId: number, address: string, assets: AssetTransferEntity[]): Promise<TokenBalance[]> {
18-
const alchemy = this.getAlchemy(chainId);
19-
2018
const contractAddresses = assets.filter((a) => a.address != null).map((a) => a.address);
19+
return this.getTokenBalancesByAddresses(chainId, address, contractAddresses);
20+
}
2121

22-
const tokenBalancesResponse = await alchemy.core.getTokenBalances(address, contractAddresses);
23-
22+
async getTokenBalancesByAddresses(chainId: number, walletAddress: string, contractAddresses: string[]): Promise<TokenBalance[]> {
23+
const alchemy = this.getAlchemy(chainId);
24+
const tokenBalancesResponse = await alchemy.core.getTokenBalances(walletAddress, contractAddresses);
2425
return tokenBalancesResponse.tokenBalances;
2526
}
2627

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { BlockchainModule } from 'src/integration/blockchain/blockchain.module';
4+
import { AlchemyModule } from '../alchemy/alchemy.module';
5+
import { BoltzController } from './controllers/boltz.controller';
6+
import { AssetBoltzEntity } from './entities/asset-boltz.entity';
7+
import { AssetBoltzRepository } from './repositories/asset-boltz.repository';
8+
import { BoltzBalanceService } from './services/boltz-balance.service';
9+
10+
@Module({
11+
imports: [TypeOrmModule.forFeature([AssetBoltzEntity]), BlockchainModule, AlchemyModule],
12+
controllers: [BoltzController],
13+
providers: [AssetBoltzRepository, BoltzBalanceService],
14+
exports: [],
15+
})
16+
export class BoltzModule {}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
3+
import { BalanceDto } from '../dto/boltz.dto';
4+
import { BoltzBalanceService } from '../services/boltz-balance.service';
5+
6+
@ApiTags('Boltz')
7+
@Controller('boltz')
8+
export class BoltzController {
9+
constructor(private readonly boltzBalanceService: BoltzBalanceService) {}
10+
11+
@Get('balance')
12+
@ApiOperation({ summary: 'Get Boltz wallet balances (public)' })
13+
@ApiOkResponse({ type: [BalanceDto] })
14+
async getWalletBalance(): Promise<BalanceDto[]> {
15+
return this.boltzBalanceService.getWalletBalance();
16+
}
17+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { Blockchain } from 'src/shared/enums/blockchain.enum';
3+
4+
export enum Direction {
5+
OUTGOING = 'outgoing',
6+
INCOMING = 'incoming'
7+
}
8+
9+
export class BalanceDto {
10+
@ApiProperty({ description: 'Blockchain/Network', enum: Blockchain })
11+
blockchain: Blockchain;
12+
13+
@ApiProperty({ description: 'Asset symbol (e.g. BTC, cBTC, JUSD, USDC, USDT, WBTC)' })
14+
asset: string;
15+
16+
@ApiProperty({ description: 'Balance amount' })
17+
balance: number;
18+
19+
@ApiPropertyOptional({ description: 'Direction (only for Lightning)', required: false, enum: Direction })
20+
direction?: Direction;
21+
}
22+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { IEntity } from 'src/shared/db/entity';
2+
import { Blockchain } from 'src/shared/enums/blockchain.enum';
3+
import { Column, Entity, Index } from 'typeorm';
4+
5+
@Entity('asset_boltz')
6+
@Index((asset: AssetBoltzEntity) => [asset.name, asset.blockchain], { unique: true })
7+
export class AssetBoltzEntity extends IEntity {
8+
@Column()
9+
name: string;
10+
11+
@Column()
12+
blockchain: Blockchain;
13+
14+
@Column()
15+
address: string;
16+
17+
@Column({ type: 'int' })
18+
decimals: number;
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { BaseRepository } from 'src/shared/db/base.repository';
3+
import { Blockchain } from 'src/shared/enums/blockchain.enum';
4+
import { EntityManager, Equal } from 'typeorm';
5+
import { AssetBoltzEntity } from '../entities/asset-boltz.entity';
6+
7+
@Injectable()
8+
export class AssetBoltzRepository extends BaseRepository<AssetBoltzEntity> {
9+
constructor(manager: EntityManager) {
10+
super(AssetBoltzEntity, manager);
11+
}
12+
13+
async getByBlockchain(blockchain: Blockchain): Promise<AssetBoltzEntity[]> {
14+
return this.findBy({ blockchain: Equal(blockchain) });
15+
}
16+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { Injectable, OnModuleInit } from '@nestjs/common';
2+
import { ethers } from 'ethers';
3+
import { GetConfig } from 'src/config/config';
4+
import { BitcoinClient } from 'src/integration/blockchain/bitcoin/bitcoin-client';
5+
import { BitcoinService } from 'src/integration/blockchain/bitcoin/bitcoin.service';
6+
import { LightningClient } from 'src/integration/blockchain/lightning/lightning-client';
7+
import { LightningHelper } from 'src/integration/blockchain/lightning/lightning-helper';
8+
import { LightningService } from 'src/integration/blockchain/lightning/services/lightning.service';
9+
import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json';
10+
import { Blockchain } from 'src/shared/enums/blockchain.enum';
11+
import { LightningLogger } from 'src/shared/services/lightning-logger';
12+
import { Util } from 'src/shared/utils/util';
13+
import { AlchemyNetworkMapper } from 'src/subdomains/alchemy/alchemy-network-mapper';
14+
import { AlchemyService } from 'src/subdomains/alchemy/services/alchemy.service';
15+
import { EvmUtil } from 'src/subdomains/evm/evm.util';
16+
import { BalanceDto, Direction } from '../dto/boltz.dto';
17+
import { AssetBoltzRepository } from '../repositories/asset-boltz.repository';
18+
19+
interface ChainConfig {
20+
blockchain: Blockchain;
21+
chainId: number;
22+
usesAlchemy: boolean;
23+
}
24+
25+
@Injectable()
26+
export class BoltzBalanceService implements OnModuleInit {
27+
private readonly logger = new LightningLogger(BoltzBalanceService);
28+
29+
private readonly bitcoinClient: BitcoinClient;
30+
private readonly lightningClient: LightningClient;
31+
32+
private evmWalletAddress = '';
33+
private citreaProvider: ethers.providers.JsonRpcProvider | null = null;
34+
private chains: ChainConfig[] = [];
35+
36+
constructor(
37+
bitcoinService: BitcoinService,
38+
lightningService: LightningService,
39+
private readonly alchemyService: AlchemyService,
40+
private readonly assetBoltzRepository: AssetBoltzRepository,
41+
) {
42+
this.bitcoinClient = bitcoinService.getDefaultClient();
43+
this.lightningClient = lightningService.getDefaultClient();
44+
}
45+
46+
onModuleInit(): void {
47+
const config = GetConfig();
48+
this.evmWalletAddress = config.boltz.evmWalletAddress;
49+
const blockchainConfig = config.blockchain;
50+
51+
this.chains = [
52+
{ blockchain: Blockchain.ETHEREUM, chainId: blockchainConfig.ethereum.chainId, usesAlchemy: true },
53+
{ blockchain: Blockchain.POLYGON, chainId: blockchainConfig.polygon.chainId, usesAlchemy: true },
54+
{ blockchain: Blockchain.CITREA, chainId: blockchainConfig.citrea.chainId, usesAlchemy: false },
55+
];
56+
57+
if (blockchainConfig.citrea.gatewayUrl) {
58+
this.citreaProvider = new ethers.providers.JsonRpcProvider(blockchainConfig.citrea.gatewayUrl);
59+
}
60+
}
61+
62+
async getWalletBalance(): Promise<BalanceDto[]> {
63+
const balances: BalanceDto[] = [];
64+
65+
balances.push(... await this.getBtcBalances());
66+
balances.push(... await this.getLightningBalances());
67+
balances.push(... await this.getEvmBalances());
68+
69+
return balances;
70+
}
71+
72+
private async getBtcBalances(): Promise<BalanceDto[]> {
73+
const balances: BalanceDto[] = [];
74+
75+
try {
76+
const onchainNode = await this.bitcoinClient.getWalletBalance();
77+
balances.push({ blockchain: Blockchain.BITCOIN, asset: 'BTC', balance: LightningHelper.satToBtc(onchainNode) });
78+
} catch (error) {
79+
this.logger.warn(`Failed to fetch BTC onchain balance: ${error.message}`);
80+
}
81+
82+
try {
83+
const lndNode = await this.lightningClient.getLndConfirmedWalletBalance();
84+
balances.push({ blockchain: Blockchain.LIGHTNING, asset: 'BTC', balance: LightningHelper.satToBtc(lndNode) });
85+
} catch (error) {
86+
this.logger.warn(`Failed to fetch LND wallet balance: ${error.message}`);
87+
}
88+
89+
return balances;
90+
}
91+
92+
private async getLightningBalances(): Promise<BalanceDto[]> {
93+
const balances: BalanceDto[] = [];
94+
95+
try {
96+
const channels = await this.lightningClient.getChannels();
97+
const outgoing = channels.reduce((sum, ch) => sum + Number(ch.local_balance), 0);
98+
const incoming = channels.reduce((sum, ch) => sum + Number(ch.remote_balance), 0);
99+
100+
balances.push({ blockchain: Blockchain.LIGHTNING, asset: 'BTC', balance: LightningHelper.satToBtc(outgoing), direction: Direction.OUTGOING});
101+
balances.push({ blockchain: Blockchain.LIGHTNING, asset: 'BTC', balance: LightningHelper.satToBtc(incoming), direction: Direction.INCOMING });
102+
} catch (error) {
103+
this.logger.warn(`Failed to fetch Lightning balance: ${error.message}`);
104+
}
105+
106+
return balances;
107+
}
108+
109+
private async getEvmBalances(): Promise<BalanceDto[]> {
110+
const balances: BalanceDto[] = [];
111+
112+
// Citrea cBTC (native balance)
113+
const citreaBalance = await this.getCitreaNativeBalance();
114+
if (citreaBalance) balances.push(citreaBalance);
115+
116+
// Token balances from all chains
117+
for (const chain of this.chains) {
118+
try {
119+
if (chain.chainId <= 0) continue;
120+
121+
const tokenBalances = chain.usesAlchemy
122+
? await this.getAlchemyTokenBalances(chain)
123+
: await this.getDirectTokenBalances(chain);
124+
balances.push(...tokenBalances);
125+
} catch (error) {
126+
this.logger.warn(`Failed to fetch balances for ${chain.blockchain}: ${error.message}`);
127+
}
128+
}
129+
130+
return balances;
131+
}
132+
133+
private async getCitreaNativeBalance(): Promise<BalanceDto | null> {
134+
if (!this.citreaProvider || !this.evmWalletAddress) return null;
135+
136+
try {
137+
const balanceWei = await this.citreaProvider.getBalance(this.evmWalletAddress);
138+
const balance = EvmUtil.fromWeiAmount(balanceWei.toString());
139+
140+
return { blockchain: Blockchain.CITREA, asset: 'cBTC', balance };
141+
} catch (error) {
142+
this.logger.warn(`Failed to fetch Citrea native balance: ${error.message}`);
143+
return null;
144+
}
145+
}
146+
147+
private async getAlchemyTokenBalances(chain: ChainConfig): Promise<BalanceDto[]> {
148+
const balances: BalanceDto[] = [];
149+
150+
try {
151+
if (!AlchemyNetworkMapper.toAlchemyNetworkByChainId(chain.chainId)) return balances;
152+
153+
if (!this.evmWalletAddress) return balances;
154+
155+
const tokens = await this.assetBoltzRepository.getByBlockchain(chain.blockchain);
156+
if (tokens.length === 0) return balances;
157+
158+
const tokenAddresses = tokens.map((t) => t.address);
159+
const tokenBalances = await this.alchemyService.getTokenBalancesByAddresses(chain.chainId, this.evmWalletAddress, tokenAddresses);
160+
161+
for (const tokenBalance of tokenBalances) {
162+
const token = tokens.find((t) => Util.equalsIgnoreCase(t.address,tokenBalance.contractAddress));
163+
if (!token) continue;
164+
165+
const rawBalance = BigInt(tokenBalance.tokenBalance ?? '0');
166+
167+
balances.push({
168+
blockchain: chain.blockchain,
169+
asset: token.name,
170+
balance: EvmUtil.fromWeiAmount(rawBalance.toString(), token.decimals),
171+
});
172+
}
173+
} catch (error) {
174+
this.logger.warn(`Failed to fetch Alchemy token balances for ${chain.blockchain}: ${error.message}`);
175+
}
176+
177+
return balances;
178+
}
179+
180+
private async getDirectTokenBalances(chain: ChainConfig): Promise<BalanceDto[]> {
181+
const balances: BalanceDto[] = [];
182+
183+
if (!this.citreaProvider || !this.evmWalletAddress) return balances;
184+
if (chain.blockchain !== Blockchain.CITREA) return balances;
185+
186+
const tokens = await this.assetBoltzRepository.getByBlockchain(chain.blockchain);
187+
if (tokens.length === 0) return balances;
188+
189+
for (const token of tokens) {
190+
try {
191+
const contract = new ethers.Contract(token.address, ERC20_ABI, this.citreaProvider);
192+
const rawBalance: ethers.BigNumber = await contract.balanceOf(this.evmWalletAddress);
193+
194+
balances.push({
195+
blockchain: chain.blockchain,
196+
asset: token.name,
197+
balance: EvmUtil.fromWeiAmount(rawBalance.toString(), token.decimals),
198+
});
199+
} catch (error) {
200+
this.logger.warn(`Failed to fetch ${token.name} balance on ${chain.blockchain}: ${error.message}`);
201+
}
202+
}
203+
204+
return balances;
205+
}
206+
}

src/subdomains/user/user.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { LnbitsWebhookModule } from 'src/integration/blockchain/lightning/lnbits
44
import { IntegrationModule } from 'src/integration/integration.module';
55
import { SharedModule } from 'src/shared/shared.module';
66
import { AuthService } from 'src/subdomains/user/application/services/auth.service';
7+
import { BoltzModule } from '../boltz/boltz.module';
78
import { LightningTransactionModule } from '../lightning/lightning-transaction.module';
89
import { AssetModule } from '../master-data/asset/asset.module';
910
import { MonitoringModule } from '../monitoring/monitoring.module';
@@ -43,6 +44,7 @@ import { WalletEntity } from './domain/entities/wallet.entity';
4344
SharedModule,
4445
IntegrationModule,
4546
MonitoringModule,
47+
BoltzModule,
4648
AssetModule,
4749
LnbitsWebhookModule,
4850
LightningTransactionModule,

0 commit comments

Comments
 (0)