Skip to content

Commit 72c8a82

Browse files
authored
Add all 4 transaction IDs for EVM Chain Swaps (#122)
* Add all 4 transaction IDs for EVM Chain Swaps (#121) Integrate Ponder-Claim database to fetch claim TX hashes: - Add Ponder PostgreSQL connection pool and configuration - Extend SwapDto with preimageHash, preimage, version, chain IDs - Add sourceClaimTxId and destClaimTxId fields from Ponder-Claim DB - Create chain ID mapping utility for symbol-to-chainId resolution - Query lockups table by preimageHash to match claim TXs to swaps * Fix Ponder SQL query to use snake_case column names Ponder generates PostgreSQL tables with snake_case column names: - preimageHash → preimage_hash - chainId → chain_id - claimTxHash → claim_tx_hash Also remove unused duplicate method and consolidate into single fetchClaimTxsFromPonder() method. * Fix preimageHash format mismatch between Boltz and Ponder Boltz stores preimageHash WITHOUT 0x prefix (64 hex chars) Ponder stores preimageHash WITH 0x prefix (from Ethereum events) Normalize all hashes to 0x prefix before querying Ponder and when looking up in the claimTxMap. * Fix destChainId example in DTO (4114 is mainnet, not 5115)
1 parent 4b55261 commit 72c8a82

5 files changed

Lines changed: 245 additions & 9 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,17 @@ DEBUG_API_URL=
1818
# Azure Application Insights (for log-debug.sh)
1919
AZURE_APP_INSIGHTS_APP_ID=
2020
AZURE_APP_INSIGHTS_API_KEY=
21+
22+
# Boltz PostgreSQL (for swap statistics)
23+
BOLTZ_PG_HOST=
24+
BOLTZ_PG_PORT=5432
25+
BOLTZ_PG_DATABASE=
26+
BOLTZ_PG_USER=
27+
BOLTZ_PG_PASSWORD=
28+
29+
# Ponder-Claim PostgreSQL (for claim transaction hashes)
30+
PONDER_PG_HOST=
31+
PONDER_PG_PORT=5432
32+
PONDER_PG_DATABASE=
33+
PONDER_PG_USER=
34+
PONDER_PG_PASSWORD=

src/config/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ export class Configuration {
193193
password: process.env.BOLTZ_PG_PASSWORD ?? '',
194194
};
195195

196+
ponderPostgres = {
197+
host: process.env.PONDER_PG_HOST ?? '',
198+
port: parseInt(process.env.PONDER_PG_PORT ?? '5432'),
199+
database: process.env.PONDER_PG_DATABASE ?? '',
200+
user: process.env.PONDER_PG_USER ?? '',
201+
password: process.env.PONDER_PG_PASSWORD ?? '',
202+
};
203+
196204
request = {
197205
knownIps: process.env.REQUEST_KNOWN_IPS?.split(',') ?? [],
198206
limitCheck: process.env.REQUEST_LIMIT_CHECK === 'true',
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Chain ID constants for EVM networks
3+
*/
4+
export const CHAIN_IDS = {
5+
// Mainnets
6+
ETHEREUM: 1,
7+
POLYGON: 137,
8+
CITREA: 4114,
9+
10+
// Testnets
11+
CITREA_TESTNET: 5115,
12+
POLYGON_AMOY: 80002,
13+
} as const;
14+
15+
/**
16+
* Maps currency symbols to their respective chain IDs.
17+
* Used to determine which chain a swap leg is on.
18+
*/
19+
export const SYMBOL_TO_CHAIN_ID: Record<string, number> = {
20+
// Polygon
21+
USDT_POLYGON: CHAIN_IDS.POLYGON,
22+
USDC_POLYGON: CHAIN_IDS.POLYGON,
23+
24+
// Ethereum
25+
USDT_ETH: CHAIN_IDS.ETHEREUM,
26+
USDC_ETH: CHAIN_IDS.ETHEREUM,
27+
ETH: CHAIN_IDS.ETHEREUM,
28+
29+
// Citrea Mainnet
30+
JUSD: CHAIN_IDS.CITREA,
31+
cBTC: CHAIN_IDS.CITREA,
32+
33+
// Citrea Testnet (fallback for testnet symbols)
34+
JUSD_CITREA: CHAIN_IDS.CITREA,
35+
};
36+
37+
/**
38+
* Get chain ID for a currency symbol.
39+
* Returns undefined if symbol is not an EVM currency (e.g., Lightning).
40+
*/
41+
export function getChainIdForSymbol(symbol: string): number | undefined {
42+
// Direct lookup
43+
if (SYMBOL_TO_CHAIN_ID[symbol]) {
44+
return SYMBOL_TO_CHAIN_ID[symbol];
45+
}
46+
47+
// Try uppercase
48+
const upperSymbol = symbol.toUpperCase();
49+
if (SYMBOL_TO_CHAIN_ID[upperSymbol]) {
50+
return SYMBOL_TO_CHAIN_ID[upperSymbol];
51+
}
52+
53+
// Check for known prefixes/patterns
54+
if (symbol.includes('POLYGON') || symbol.includes('_POLYGON')) {
55+
return CHAIN_IDS.POLYGON;
56+
}
57+
if (symbol.includes('ETH') && !symbol.includes('_')) {
58+
return CHAIN_IDS.ETHEREUM;
59+
}
60+
if (symbol.includes('CITREA') || symbol === 'JUSD' || symbol === 'cBTC') {
61+
return CHAIN_IDS.CITREA;
62+
}
63+
64+
return undefined;
65+
}

src/subdomains/support/dto/swap-stats.dto.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,23 @@ export class SwapDto {
6969
@ApiProperty()
7070
updatedAt: string;
7171

72+
// Crypto details
73+
@ApiPropertyOptional({ description: 'Preimage hash (links swap across chains)' })
74+
preimageHash?: string;
75+
76+
@ApiPropertyOptional({ description: 'Preimage (revealed after claim)' })
77+
preimage?: string;
78+
79+
@ApiPropertyOptional({ description: 'Swap version' })
80+
version?: number;
81+
82+
// Source chain details
7283
@ApiProperty()
7384
sourceSymbol: string;
7485

86+
@ApiPropertyOptional({ description: 'Source chain ID (e.g., 137 for Polygon)' })
87+
sourceChainId?: number;
88+
7589
@ApiPropertyOptional()
7690
sourceAddress?: string;
7791

@@ -81,12 +95,19 @@ export class SwapDto {
8195
@ApiPropertyOptional()
8296
sourceAmount?: string;
8397

84-
@ApiPropertyOptional()
98+
@ApiPropertyOptional({ description: 'User lockup TX on source chain' })
8599
sourceTxId?: string;
86100

101+
@ApiPropertyOptional({ description: 'Boltz claim TX on source chain (from ponder-claim)' })
102+
sourceClaimTxId?: string;
103+
104+
// Destination chain details
87105
@ApiProperty()
88106
destSymbol: string;
89107

108+
@ApiPropertyOptional({ description: 'Destination chain ID (e.g., 4114 for Citrea)' })
109+
destChainId?: number;
110+
90111
@ApiPropertyOptional()
91112
destAddress?: string;
92113

@@ -96,8 +117,11 @@ export class SwapDto {
96117
@ApiPropertyOptional()
97118
destAmount?: string;
98119

99-
@ApiPropertyOptional()
120+
@ApiPropertyOptional({ description: 'Boltz lockup TX on destination chain' })
100121
destTxId?: string;
122+
123+
@ApiPropertyOptional({ description: 'User claim TX on destination chain (from ponder-claim)' })
124+
destClaimTxId?: string;
101125
}
102126

103127
export class SwapStatsResponseDto {

src/subdomains/support/services/support.service.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@ import { DebugLogQueryTemplates, MssqlDebugConfig } from '../dto/debug.config';
1010
import { LogQueryDto, LogQueryResult } from '../dto/log-query.dto';
1111
import { SqlQueryValidator } from './sql-query-validator';
1212
import { SwapDto, SwapStatsQueryDto, SwapStatsResponseDto, SwapStatusFilter, SwapType } from '../dto/swap-stats.dto';
13+
import { getChainIdForSymbol } from '../constants/chain-ids';
14+
15+
interface PonderLockup {
16+
preimage_hash: string;
17+
chain_id: number;
18+
claim_tx_hash: string | null;
19+
}
1320

1421
@Injectable()
1522
export class SupportService implements OnModuleDestroy {
1623
private readonly logger = new LightningLogger(SupportService);
1724
private readonly sqlValidator = new SqlQueryValidator();
1825
private boltzPool: Pool | null = null;
26+
private ponderPool: Pool | null = null;
1927

2028
constructor(
2129
private readonly dataSource: DataSource,
@@ -26,6 +34,9 @@ export class SupportService implements OnModuleDestroy {
2634
if (this.boltzPool) {
2735
await this.boltzPool.end();
2836
}
37+
if (this.ponderPool) {
38+
await this.ponderPool.end();
39+
}
2940
}
3041

3142
private getBoltzPool(): Pool {
@@ -48,6 +59,27 @@ export class SupportService implements OnModuleDestroy {
4859
return this.boltzPool;
4960
}
5061

62+
private getPonderPool(): Pool | null {
63+
if (!this.ponderPool) {
64+
const pgConfig = Config.ponderPostgres;
65+
if (!pgConfig.host || !pgConfig.database) {
66+
// Ponder is optional - return null if not configured
67+
return null;
68+
}
69+
this.ponderPool = new Pool({
70+
host: pgConfig.host,
71+
port: pgConfig.port,
72+
database: pgConfig.database,
73+
user: pgConfig.user,
74+
password: pgConfig.password,
75+
max: 5,
76+
idleTimeoutMillis: 30000,
77+
connectionTimeoutMillis: 10000,
78+
});
79+
}
80+
return this.ponderPool;
81+
}
82+
5183
async getRawData(query: DbQueryDto): Promise<any> {
5284
const request = this.dataSource
5385
.createQueryBuilder()
@@ -199,6 +231,48 @@ export class SupportService implements OnModuleDestroy {
199231
return str.charAt(0).toLowerCase() + str.slice(1).split('_').join('.');
200232
}
201233

234+
/**
235+
* Fetches claim TX hashes with chain IDs from Ponder-Claim database.
236+
* Returns a map of preimageHash -> array of { chainId, claimTxHash }
237+
*
238+
* Note: Ponder generates PostgreSQL tables with snake_case column names.
239+
*/
240+
private async fetchClaimTxsFromPonder(
241+
preimageHashes: string[],
242+
): Promise<Map<string, Array<{ chainId: number; claimTxHash: string }>>> {
243+
const result = new Map<string, Array<{ chainId: number; claimTxHash: string }>>();
244+
245+
if (preimageHashes.length === 0) {
246+
return result;
247+
}
248+
249+
const ponderPool = this.getPonderPool();
250+
if (!ponderPool) {
251+
return result;
252+
}
253+
254+
try {
255+
const { rows } = await ponderPool.query<PonderLockup>(
256+
`SELECT preimage_hash, chain_id, claim_tx_hash
257+
FROM lockups
258+
WHERE preimage_hash = ANY($1) AND claim_tx_hash IS NOT NULL`,
259+
[preimageHashes],
260+
);
261+
262+
for (const row of rows) {
263+
if (!row.claim_tx_hash) continue;
264+
265+
const existing = result.get(row.preimage_hash) || [];
266+
existing.push({ chainId: row.chain_id, claimTxHash: row.claim_tx_hash });
267+
result.set(row.preimage_hash, existing);
268+
}
269+
} catch (e) {
270+
this.logger.warn(`Failed to fetch claim TXs from Ponder: ${e.message}`);
271+
}
272+
273+
return result;
274+
}
275+
202276
// *** PUBLIC SWAP STATS *** //
203277

204278
async getSwapStats(query: SwapStatsQueryDto): Promise<SwapStatsResponseDto> {
@@ -256,9 +330,10 @@ export class SupportService implements OnModuleDestroy {
256330
}
257331

258332
private async fetchChainSwaps(pool: Pool, query: SwapStatsQueryDto): Promise<SwapDto[]> {
259-
// Fetch chain swaps with their data
333+
// Fetch chain swaps with their data (including preimageHash and preimage for claim TX lookup)
260334
const chainSwapsResult = await pool.query(`
261-
SELECT cs.*,
335+
SELECT cs.id, cs.pair, cs."orderSide", cs.status, cs."failureReason", cs.fee,
336+
cs.referral, cs."createdAt", cs."updatedAt", cs."preimageHash", cs.preimage, cs.version,
262337
sd_base.symbol as base_symbol, sd_base."lockupAddress" as base_lockup,
263338
sd_base."claimAddress" as base_claim, sd_base."expectedAmount" as base_expected,
264339
sd_base.amount as base_amount, sd_base."transactionId" as base_tx,
@@ -274,8 +349,18 @@ export class SupportService implements OnModuleDestroy {
274349
LIMIT 1000
275350
`);
276351

352+
// Extract preimageHashes for claim TX lookup
353+
// Boltz stores without 0x prefix, Ponder stores with 0x prefix
354+
const preimageHashes = chainSwapsResult.rows
355+
.map((row) => row.preimageHash as string)
356+
.filter((hash): hash is string => !!hash)
357+
.map((hash) => (hash.startsWith('0x') ? hash : `0x${hash}`));
358+
359+
// Fetch claim TXs from Ponder-Claim DB
360+
const claimTxMap = await this.fetchClaimTxsFromPonder(preimageHashes);
361+
277362
return chainSwapsResult.rows
278-
.map((row) => this.mapChainSwap(row))
363+
.map((row) => this.mapChainSwap(row, claimTxMap))
279364
.filter((swap) => this.matchesFilter(swap, query));
280365
}
281366

@@ -307,12 +392,42 @@ export class SupportService implements OnModuleDestroy {
307392
.filter((swap) => this.matchesFilter(swap, query));
308393
}
309394

310-
private mapChainSwap(row: Record<string, unknown>): SwapDto {
395+
private mapChainSwap(
396+
row: Record<string, unknown>,
397+
claimTxMap?: Map<string, Array<{ chainId: number; claimTxHash: string }>>,
398+
): SwapDto {
311399
const pair = row.pair as string;
312400
const [base, quote] = pair.split('/');
313401
const orderSide = row.orderSide as number;
314402
const direction = orderSide === 1 ? `${base} -> ${quote}` : `${quote} -> ${base}`;
315403

404+
// Determine source and destination symbols
405+
const sourceSymbol = orderSide === 1 ? base : quote;
406+
const destSymbol = orderSide === 1 ? quote : base;
407+
408+
// Get chain IDs for source and destination
409+
const sourceChainId = getChainIdForSymbol(sourceSymbol);
410+
const destChainId = getChainIdForSymbol(destSymbol);
411+
412+
// Look up claim TXs from Ponder data
413+
let sourceClaimTxId: string | undefined;
414+
let destClaimTxId: string | undefined;
415+
416+
const preimageHash = row.preimageHash as string;
417+
if (preimageHash && claimTxMap) {
418+
// Normalize to 0x prefix for lookup (Ponder stores with 0x, Boltz without)
419+
const normalizedHash = preimageHash.startsWith('0x') ? preimageHash : `0x${preimageHash}`;
420+
const claimTxs = claimTxMap.get(normalizedHash) || [];
421+
422+
for (const claimTx of claimTxs) {
423+
if (sourceChainId && claimTx.chainId === sourceChainId) {
424+
sourceClaimTxId = claimTx.claimTxHash;
425+
} else if (destChainId && claimTx.chainId === destChainId) {
426+
destClaimTxId = claimTx.claimTxHash;
427+
}
428+
}
429+
}
430+
316431
return {
317432
type: 'Chain Swap',
318433
id: row.id as string,
@@ -324,16 +439,26 @@ export class SupportService implements OnModuleDestroy {
324439
referral: (row.referral as string) || undefined,
325440
createdAt: this.toIsoString(row.createdAt),
326441
updatedAt: this.toIsoString(row.updatedAt),
327-
sourceSymbol: orderSide === 1 ? base : quote,
442+
// Crypto details
443+
preimageHash: preimageHash || undefined,
444+
preimage: (row.preimage as string) || undefined,
445+
version: row.version as number | undefined,
446+
// Source chain
447+
sourceSymbol,
448+
sourceChainId,
328449
sourceAddress: (orderSide === 1 ? row.base_lockup : row.quote_lockup) as string,
329450
sourceExpectedAmount: (orderSide === 1 ? row.base_expected : row.quote_expected) as string,
330451
sourceAmount: (orderSide === 1 ? row.base_amount : row.quote_amount) as string,
331452
sourceTxId: (orderSide === 1 ? row.base_tx : row.quote_tx) as string,
332-
destSymbol: orderSide === 1 ? quote : base,
333-
destAddress: ((orderSide === 1 ? row.quote_claim || row.quote_lockup : row.base_claim || row.base_lockup) as string),
453+
sourceClaimTxId,
454+
// Destination chain
455+
destSymbol,
456+
destChainId,
457+
destAddress: (orderSide === 1 ? row.quote_claim || row.quote_lockup : row.base_claim || row.base_lockup) as string,
334458
destExpectedAmount: (orderSide === 1 ? row.quote_expected : row.base_expected) as string,
335459
destAmount: (orderSide === 1 ? row.quote_amount : row.base_amount) as string,
336460
destTxId: (orderSide === 1 ? row.quote_tx : row.base_tx) as string,
461+
destClaimTxId,
337462
};
338463
}
339464

0 commit comments

Comments
 (0)