forked from ZyntariHQ/Invoisio
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathevm-watcher.service.ts
More file actions
177 lines (156 loc) · 7.39 KB
/
Copy pathevm-watcher.service.ts
File metadata and controls
177 lines (156 loc) · 7.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../infra/prisma/prisma.service';
import { JsonRpcProvider, Interface, formatUnits, id, type Log, type Filter } from 'ethers';
@Injectable()
export class EvmWatcherService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EvmWatcherService.name);
private provider?: JsonRpcProvider;
private stopFns: (() => void)[] = [];
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {}
async onModuleInit() {
const rpcUrl = this.config.get<string>('evm.rpcUrl');
const chainId = this.config.get<number>('evm.chainId');
const usdcAddress = (this.config.get<string>('evm.usdcAddress') || '').toLowerCase();
const routerAddress = (this.config.get<string>('evm.routerAddress') || '').toLowerCase();
if (!rpcUrl || !chainId) {
this.logger.warn('EVM watcher disabled: rpcUrl/chainId missing');
return;
}
// If merchant address is not set, we still proceed and match per-payment merchantAddress.
this.provider = new JsonRpcProvider(rpcUrl, chainId);
this.logger.log(`EVM watcher connected: chainId=${chainId}`);
// ETH transfers: scan blocks and match txs where to == merchant
const onBlock = async (blockNumber: number) => {
try {
const block = await this.provider!.getBlock(blockNumber);
if (!block?.transactions?.length) return;
for (const txHash of block.transactions) {
try {
const tx = await this.provider!.getTransaction(txHash);
if (!tx) continue;
if (!tx.to) continue;
if (tx.value === 0n) continue;
const fromAddr = tx.from ? tx.from.toLowerCase() : '';
const toAddr = tx.to.toLowerCase();
await this.handleEthPayment(fromAddr, toAddr, tx.hash, tx.value);
} catch (innerErr: any) {
this.logger.warn(`Tx fetch error: ${innerErr?.message || innerErr}`);
}
}
} catch (err: any) {
this.logger.warn(`Block scan error: ${err?.message || err}`);
}
};
this.provider.on('block', onBlock);
this.stopFns.push(() => this.provider?.off('block', onBlock));
// USDC Transfer events (match per-payment merchantAddress)
if (usdcAddress) {
const ERC20_TRANSFER = id('Transfer(address,address,uint256)');
const filter: Filter = {
address: usdcAddress,
topics: [ERC20_TRANSFER],
};
const iface = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']);
const onLog = async (log: Log) => {
try {
const parsed = iface.parseLog(log);
if (!parsed) return;
const from = (parsed.args.from as string).toLowerCase();
const to = (parsed.args.to as string).toLowerCase();
const value = parsed.args.value as bigint;
if (!log.transactionHash) return;
await this.handleTokenPayment('USDC', from, to, log.transactionHash, value, 6);
} catch (err: any) {
this.logger.warn(`USDC log parse error: ${err?.message || err}`);
}
};
this.provider.on(filter, onLog);
this.stopFns.push(() => this.provider?.off(filter, onLog));
}
// PaymentRouter events: match ETH via router and USDC via router using emitted merchant
if (routerAddress) {
const PAYMENT_RECEIVED = id('PaymentReceived(bytes32,address,address,address,uint256)');
const routerFilter: Filter = { address: routerAddress, topics: [PAYMENT_RECEIVED] };
const routerIface = new Interface(['event PaymentReceived(bytes32 indexed invoiceId, address indexed payer, address indexed token, address merchant, uint256 amount)']);
const onRouterLog = async (log: Log) => {
try {
const parsed = routerIface.parseLog(log);
if (!parsed || !log.transactionHash) return;
const payer = (parsed.args.payer as string).toLowerCase();
const tokenAddr = (parsed.args.token as string).toLowerCase();
const merchant = (parsed.args.merchant as string).toLowerCase();
const amount = parsed.args.amount as bigint;
// ETH via router
if (tokenAddr === '0x0000000000000000000000000000000000000000') {
await this.handleEthPayment(payer, merchant, log.transactionHash, amount);
}
// USDC via router
else if (usdcAddress && tokenAddr === usdcAddress) {
await this.handleTokenPayment('USDC', payer, merchant, log.transactionHash, amount, 6);
}
} catch (err: any) {
this.logger.warn(`Router log parse error: ${err?.message || err}`);
}
};
this.provider.on(routerFilter, onRouterLog);
this.stopFns.push(() => this.provider?.off(routerFilter, onRouterLog));
}
}
async onModuleDestroy() {
for (const stop of this.stopFns) {
try { stop(); } catch {}
}
this.stopFns = [];
}
private async handleEthPayment(from: string, to: string, txHash: string, valueWei: bigint) {
const amountEth = formatUnits(valueWei, 18);
await this.matchAndCompletePayment({ token: 'ETH', from, to, amountStr: amountEth, txHash });
}
private async handleTokenPayment(token: 'USDC' | 'USDT', from: string, to: string, txHash: string, value: bigint, decimals: number) {
const amount = formatUnits(value, decimals);
await this.matchAndCompletePayment({ token, from, to, amountStr: amount, txHash });
}
private async matchAndCompletePayment(params: { token: 'ETH' | 'USDC' | 'USDT'; from: string; to?: string; amountStr: string; txHash: string; }) {
try {
const user = await this.prisma.user.findFirst({ where: { walletAddress: params.from } });
if (!user) return;
// Find the most recent pending payment for this user & token with close amount
const pending = await this.prisma.payment.findMany({
where: { userId: user.id, status: 'pending', token: params.token },
orderBy: { createdAt: 'desc' },
take: 5,
});
if (!pending.length) return;
const matchTolerance = 1e-6; // accept minor rounding differences
const toNumber = (s: string) => {
const n = Number(s);
return Number.isFinite(n) ? n : NaN;
};
const defaultMerchant = (this.config.get<string>('evm.merchantAddress') || '').toLowerCase();
let matched: { id: string } | null = null;
for (const p of pending) {
const pMerchant = (((p as any).merchantAddress as string | undefined) || defaultMerchant).toLowerCase();
if (params.to) {
const toLower = params.to.toLowerCase();
if (!pMerchant || pMerchant !== toLower) continue;
}
const a = toNumber(p.amount);
const b = toNumber(params.amountStr);
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
if (Math.abs(a - b) <= matchTolerance) { matched = { id: p.id }; break; }
}
if (!matched) return;
await this.prisma.payment.update({
where: { id: matched.id },
data: { status: 'completed', transactionHash: params.txHash },
});
this.logger.log(`Payment matched & completed: token=${params.token} user=${user.id} tx=${params.txHash}`);
} catch (err: any) {
this.logger.warn(`matchAndCompletePayment error: ${err?.message || err}`);
}
}
}