fix(deposits): stop runaway duplicate deposit rows, fix check-deposits reliability

Root cause chain found while investigating why TRC20/TON/Polkadot deposits
were never being credited after the self-hosted Supabase migration:

- DEPOSIT_TRON_HD_MNEMONIC, TRONGRID_API_KEY, DEPOSIT_TON_ADDRESS,
  DEPOSIT_POLKADOT_ADDRESS and the pg_cron job itself were never carried
  over during the 2026-04-09 migration, so check-deposits never ran.
- Once reconnected, the TRC20 loop was fully sequential (one address at a
  time) and hit the edge function's CPU/time budget with 50+ users -
  parallelized with a bounded concurrency of 8.
- The dedup check (select-by-tx_hash + .single()) breaks permanently once
  2+ rows ever share a tx_hash - .single() then errors on every future
  lookup, so the guard silently stops working and every cron tick
  reinserts. This is what produced 50k+ and 30k+ duplicate rows for two
  historical deposits back in April. Replaced with upsert +
  onConflict/ignoreDuplicates against a new unique constraint on tx_hash,
  so duplicates are impossible at the DB level regardless of app-level
  races.
- deposit_index 0 is the platform admin account and also used as the
  treasury sweep destination - excluded from the TRC20 scan so internal
  sweep transfers landing on it are never mistaken for a customer deposit.
This commit is contained in:
2026-07-12 19:21:54 -07:00
parent 84f951420e
commit 8d1e5f6af5
4 changed files with 116 additions and 97 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pezkuwi-telegram-miniapp",
"version": "1.0.239",
"version": "1.0.240",
"type": "module",
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
"author": "Pezkuwichain Team",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.239",
"buildTime": "2026-07-12T12:07:48.783Z",
"buildNumber": 1783858068783
"version": "1.0.240",
"buildTime": "2026-07-13T02:21:54.972Z",
"buildNumber": 1783909314972
}
+104 -93
View File
@@ -118,17 +118,6 @@ async function checkTonDeposits(
if (amount < MIN_DEPOSIT) continue;
// Check if already processed
const { data: existing } = await supabase
.from('tg_deposits')
.select('id')
.eq('tx_hash', txHash)
.single();
if (existing) continue;
result.found++;
// Find user by memo code
const { data: depositCode } = await supabase
.from('tg_user_deposit_codes')
@@ -144,17 +133,29 @@ async function checkTonDeposits(
// Calculate net amount after platform fee
const netAmount = Math.max(0, amount - TON_FEE);
// Create deposit record with net amount
const { error } = await supabase.from('tg_deposits').insert({
user_id: depositCode.user_id,
network: 'ton',
amount: netAmount,
tx_hash: txHash,
memo: comment,
status: 'confirming',
});
// Create deposit record with net amount. tx_hash has a unique DB
// constraint - on conflict (already processed on a prior run) this
// is a no-op, avoiding the check-then-insert race that used to
// produce runaway duplicate rows.
const { error, data: inserted } = await supabase
.from('tg_deposits')
.upsert(
{
user_id: depositCode.user_id,
network: 'ton',
amount: netAmount,
tx_hash: txHash,
memo: comment,
status: 'confirming',
},
{ onConflict: 'tx_hash', ignoreDuplicates: true }
)
.select('id');
if (!error) result.processed++;
if (!error && inserted && inserted.length > 0) {
result.found++;
result.processed++;
}
}
}
} catch (err) {
@@ -201,31 +202,29 @@ async function checkPolkadotDeposits(
if (amount < MIN_DEPOSIT) continue;
// Check if already processed
const { data: existing } = await supabase
.from('tg_deposits')
.select('id')
.eq('tx_hash', txHash)
.single();
if (existing) continue;
result.found++;
// Calculate net amount after platform fee
const netAmount = Math.max(0, amount - POLKADOT_FEE);
// For Polkadot, memo might be in extrinsic data
// Try to find user by checking system.remark in same block
// For now, create as pending without user
const { error } = await supabase.from('tg_deposits').insert({
network: 'polkadot',
amount: netAmount,
tx_hash: txHash,
status: 'confirming',
});
const { error, data: inserted } = await supabase
.from('tg_deposits')
.upsert(
{
network: 'polkadot',
amount: netAmount,
tx_hash: txHash,
status: 'confirming',
},
{ onConflict: 'tx_hash', ignoreDuplicates: true }
)
.select('id');
if (!error) result.processed++;
if (!error && inserted && inserted.length > 0) {
result.found++;
result.processed++;
}
}
} catch (err) {
result.errors.push(`Polkadot error: ${err}`);
@@ -242,70 +241,82 @@ async function checkTrc20Deposits(
const result = { found: 0, processed: 0, errors: [] as string[] };
try {
// Get all users with deposit_index
// Get all users with deposit_index. deposit_index 0 belongs to the
// platform admin account and doubles as the treasury sweep destination -
// excluded here so internal sweep transfers landing on it are never
// mistaken for a customer deposit.
const { data: users } = await supabase
.from('tg_users')
.select('id, deposit_index')
.not('deposit_index', 'is', null)
.neq('deposit_index', 0)
.order('deposit_index', { ascending: true });
if (!users || users.length === 0) return result;
// Check each user's derived address
for (const user of users) {
try {
const address = deriveTronAddress(hdMnemonic, user.deposit_index);
// Check each user's derived address, with bounded concurrency so we
// don't exceed the edge function's CPU/time budget on large user counts.
const CONCURRENCY = 8;
for (let i = 0; i < users.length; i += CONCURRENCY) {
const batch = users.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async (user) => {
try {
const address = deriveTronAddress(hdMnemonic, user.deposit_index);
// Get TRC20 transfers to this address
const response = await fetch(
`${TRON_API}/v1/accounts/${address}/transactions/trc20?limit=20&contract_address=${TRON_USDT_CONTRACT}`,
{
headers: { 'TRON-PRO-API-KEY': Deno.env.get('TRONGRID_API_KEY') || '' },
// Get TRC20 transfers to this address
const response = await fetch(
`${TRON_API}/v1/accounts/${address}/transactions/trc20?limit=20&contract_address=${TRON_USDT_CONTRACT}`,
{
headers: { 'TRON-PRO-API-KEY': Deno.env.get('TRONGRID_API_KEY') || '' },
}
);
if (!response.ok) return;
const data = await response.json();
const transfers = data.data || [];
for (const tx of transfers) {
// Only incoming transfers
if (tx.to?.toLowerCase() !== address.toLowerCase()) continue;
const txHash = tx.transaction_id;
const amount = parseInt(tx.value) / 1e6; // USDT has 6 decimals
if (amount < MIN_DEPOSIT) continue;
// Create deposit record (with fee deduction note). tx_hash
// has a unique DB constraint - on conflict this is a no-op,
// avoiding the check-then-insert race that used to produce
// runaway duplicate rows.
const netAmount = Math.max(0, amount - TRC20_FEE);
const { error, data: inserted } = await supabase
.from('tg_deposits')
.upsert(
{
user_id: user.id,
network: 'trc20',
amount: netAmount, // Store net amount after fee
tx_hash: txHash,
status: 'confirming',
},
{ onConflict: 'tx_hash', ignoreDuplicates: true }
)
.select('id');
if (!error && inserted && inserted.length > 0) {
result.found++;
result.processed++;
}
}
} catch (err) {
// Skip individual user errors
return;
}
);
if (!response.ok) continue;
const data = await response.json();
const transfers = data.data || [];
for (const tx of transfers) {
// Only incoming transfers
if (tx.to?.toLowerCase() !== address.toLowerCase()) continue;
const txHash = tx.transaction_id;
const amount = parseInt(tx.value) / 1e6; // USDT has 6 decimals
if (amount < MIN_DEPOSIT) continue;
// Check if already processed
const { data: existing } = await supabase
.from('tg_deposits')
.select('id')
.eq('tx_hash', txHash)
.single();
if (existing) continue;
result.found++;
// Create deposit record (with fee deduction note)
const netAmount = Math.max(0, amount - TRC20_FEE);
const { error } = await supabase.from('tg_deposits').insert({
user_id: user.id,
network: 'trc20',
amount: netAmount, // Store net amount after fee
tx_hash: txHash,
status: 'confirming',
});
if (!error) result.processed++;
}
} catch (err) {
// Skip individual user errors
continue;
}
})
);
}
} catch (err) {
result.errors.push(`TRC20 error: ${err}`);
@@ -0,0 +1,8 @@
-- Prevent duplicate deposit records for the same on-chain transaction.
-- Without this constraint, a check-then-insert race in check-deposits
-- (concurrent invocations, or any transient duplicate) permanently breaks
-- the .single() dedup lookup once 2+ rows share a tx_hash, causing every
-- subsequent cron run to re-insert indefinitely. This happened in
-- production: two tx_hashes accumulated 50k+ and 30k+ duplicate rows
-- respectively before being cleaned up.
ALTER TABLE tg_deposits ADD CONSTRAINT tg_deposits_tx_hash_unique UNIQUE (tx_hash);