From 8d1c946fed0e2a208a0ad23fd91da0961b396a81 Mon Sep 17 00:00:00 2001 From: Julien Lucca Date: Wed, 1 Jul 2026 12:25:36 +0200 Subject: [PATCH] fix(claims): set claim.id to the real chain id instead of a DB serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claimAction inserted claims without an id, letting the DB serial assign it. The chain claim id is not in the claimaction payload (the contract generates it via get_available_id), so the serial only matched the chain id while it stayed in lockstep — any duplicate/orphan insert drifted it ahead, and from that point on new claims got ids that don't exist on chain, so the frontend's verifyclaim(claim.id) failed with 'Can't find claim' and admins couldn't approve them. Recover the real id from chain: claims are never deleted (verifyclaim only mutates status) and ids are assigned in creation order, so the nth chain claim for a (action, claimer) pair is the nth claimaction we process for it. chain.js queries the claim table via the byaction index and returns that id; claimAction inserts with it explicitly. On any failure it falls back to the serial (which the one-time claims-id-reconciliation realigned to the chain) rather than throw, so a chain hiccup can't crash-loop the indexer. Co-Authored-By: Claude Opus 4.8 --- src/chain.js | 78 +++++++++++++++++++++++++++++++++++++++ src/updaters/community.js | 32 ++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/chain.js diff --git a/src/chain.js b/src/chain.js new file mode 100644 index 0000000..6ce54c9 --- /dev/null +++ b/src/chain.js @@ -0,0 +1,78 @@ +const http = require('http') +const https = require('https') +const { URL } = require('url') +const config = require(`./config/${process.env.NODE_ENV || 'dev'}`) + +// Minimal POST to the EOS node (same transport style as GetActionsReader). +function post (path, body) { + return new Promise((resolve, reject) => { + const url = new URL(path, config.blockchain.url) + const data = JSON.stringify(body) + const transport = url.protocol === 'https:' ? https : http + const req = transport.request({ + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } + }, res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString())) } catch (e) { reject(e) } + }) + }) + req.setTimeout(30000, () => req.destroy(new Error('request timeout'))) + req.on('error', reject) + req.write(data) + req.end() + }) +} + +// Fetch every on-chain claim for a single action via the `byaction` secondary +// index (index_position 2). An action realistically has far fewer than the limit; +// if the node still reports more, throw so we never compute an ordinal off a +// truncated set (fail-safe — the block retries). +async function claimsForAction (communityContract, actionId) { + const res = await post('/v1/chain/get_table_rows', { + json: true, + code: communityContract, + scope: communityContract, + table: 'claim', + index_position: 2, + key_type: 'i64', + lower_bound: actionId, + upper_bound: actionId, + limit: 5000 + }) + if (!res || !Array.isArray(res.rows)) throw new Error(`get_table_rows(claim) returned no rows for action ${actionId}`) + if (res.more) throw new Error(`too many claims for action ${actionId} to page safely`) + return res.rows +} + +// Resolve the real on-chain claim id for the claim being processed. +// +// The chain assigns claim ids sequentially (get_available_id) and NEVER deletes a +// claim (verifyclaim only mutates status), and the `claimaction` payload does not +// carry the generated id. So for a given (action, claimer), the nth claim by +// ascending id is the nth `claimaction` event-source processes for that pair — in +// live AND replay. `ordinal` is the count of claims already recorded in the DB for +// this pair (0-based index of the new one). Throws if the chain doesn't have that +// many claims yet, so a transient/ordering problem retries the block instead of +// writing a wrong id. +async function resolveClaimId (communityContract, actionId, maker, ordinal) { + const mine = (await claimsForAction(communityContract, actionId)) + .filter(c => c.claimer === maker) + .sort((a, b) => Number(a.id) - Number(b.id)) + + const claim = mine[ordinal] + if (!claim) { + throw new Error( + `resolveClaimId: chain has ${mine.length} claims for action ${actionId} / ${maker}, ` + + `need index ${ordinal}. Retrying block.` + ) + } + return Number(claim.id) +} + +module.exports = { resolveClaimId } diff --git a/src/updaters/community.js b/src/updaters/community.js index d17a368..489bf52 100644 --- a/src/updaters/community.js +++ b/src/updaters/community.js @@ -3,6 +3,8 @@ const { getSymbolFromAsset, parseToken } = require('../eos_helper') +const config = require(`../config/${process.env.NODE_ENV || 'dev'}`) +const { resolveClaimId } = require('../chain') async function createCommunity (db, payload, blockInfo) { console.log(`Cambiatus >>> Create Community`, blockInfo.blockNumber) @@ -434,7 +436,37 @@ async function claimAction (db, payload, blockInfo, context) { }) if (Number(existing) > 0) return + // The DB claim.id MUST equal the chain claim id — the frontend signs + // verifyclaim(claim.id) against the chain. The `claimaction` payload does not + // carry the id (the contract generates it), so historically we let the DB serial + // assign it, which drifts from the chain id after any duplicate/extra insert and + // leaves claims unverifiable. Recover the real id from chain: the nth claim (by + // ascending id) for this (action, claimer) is the nth we process for that pair. + // + // On any failure (chain unreachable, unexpected count) we fall back to the serial + // rather than throw — a throw here becomes an unhandledRejection → process exit → + // pm2 crash-loop. The serial is realigned to the chain by the one-time + // claims-id-reconciliation, so the fallback stays correct unless a new drift is + // introduced; the explicit-id path is what makes it robust against that. + let claimId + try { + const ordinal = Number(await db.claims.count({ + action_id: payload.data.action_id, + claimer_id: payload.data.maker + })) + claimId = await resolveClaimId( + config.blockchain.contract.community, + payload.data.action_id, + payload.data.maker, + ordinal + ) + } catch (e) { + logError('Could not resolve chain claim id, falling back to serial', e) + claimId = undefined + } + const data = { + ...(claimId ? { id: claimId } : {}), action_id: payload.data.action_id, claimer_id: payload.data.maker, status: 'pending',