diff --git a/scripts/compare-claim-mirror.sh b/scripts/compare-claim-mirror.sh new file mode 100755 index 0000000..8f0c43d --- /dev/null +++ b/scripts/compare-claim-mirror.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Compare cambiatus.cm `mbrclaim` with Postgres member_action_claims, one account at a time. +# Usage: scripts/compare-claim-mirror.sh ... +# Exit 0 = identical for every account. +set -uo pipefail +CHAIN=$1; DB=$2; shift 2 +fail=0 +for acc in "$@"; do + chain=$(curl -s -X POST "$CHAIN/v1/chain/get_table_rows" \ + -d "{\"code\":\"cambiatus.cm\",\"scope\":\"$acc\",\"table\":\"mbrclaim\",\"json\":true,\"limit\":1000}" \ + | jq -c '[.rows[] | {action_id: (.action_id|tonumber), pending, approved, last_rejected_at}] | sort_by(.action_id)') + db=$(psql -X -At -d "$DB" -c "select coalesce(json_agg(json_build_object('action_id', action_id, 'pending', pending, 'approved', approved, 'last_rejected_at', coalesce(extract(epoch from last_rejected_at)::bigint, 0)) order by action_id), '[]') from member_action_claims where account = '$acc'" | jq -c '.') + if [ "$chain" = "$db" ]; then echo "OK $acc"; else echo "DIFF $acc"; echo " chain: $chain"; echo " db: $db"; fail=1; fi +done +exit $fail diff --git a/src/claim_mirror.js b/src/claim_mirror.js new file mode 100644 index 0000000..cc09ae2 --- /dev/null +++ b/src/claim_mirror.js @@ -0,0 +1,57 @@ +// Mirror of cambiatus.cm `mbrclaim` (one row per member + action) in +// member_action_claims. Applies the contract's own arithmetic; never decides anything. +// Callers pass the block-level `db` (demux's transaction), never a nested withTransaction. +const { toUTC } = require('./dates') + +async function claimOpened (db, account, actionId, blockNumber) { + await db.instance.none( + `INSERT INTO member_action_claims (account, action_id, pending, approved, updated_block) + VALUES ($1, $2, 1, 0, $3) + ON CONFLICT (account, action_id) DO UPDATE + SET pending = member_action_claims.pending + 1, updated_block = $3, updated_at = now()`, + [account, Number(actionId), blockNumber] + ) +} + +// The contract stores whole seconds (truncated). The column is timestamp(0), which ROUNDS, +// so a block at hh:mm:ss.500 would land one second late. Truncate before writing. +function wholeSeconds (date) { + return new Date(Math.floor(date.getTime() / 1000) * 1000) +} + +async function claimClosed (db, account, actionId, approved, blockTimestamp, blockNumber) { + const rejectedAt = approved ? null : toUTC(wholeSeconds(blockTimestamp)) + await db.instance.none( + `INSERT INTO member_action_claims (account, action_id, pending, approved, last_rejected_at, updated_block) + VALUES ($1, $2, 0, $3, $4, $5) + ON CONFLICT (account, action_id) DO UPDATE + SET pending = GREATEST(member_action_claims.pending - 1, 0), + approved = member_action_claims.approved + $3, + last_rejected_at = COALESCE($4, member_action_claims.last_rejected_at), + updated_block = $5, updated_at = now()`, + [account, Number(actionId), approved ? 1 : 0, rejectedAt, blockNumber] + ) +} + +async function rewarded (db, account, actionId, blockNumber) { + await db.instance.none( + `INSERT INTO member_action_claims (account, action_id, pending, approved, updated_block) + VALUES ($1, $2, 0, 1, $3) + ON CONFLICT (account, action_id) DO UPDATE + SET approved = member_action_claims.approved + 1, updated_block = $3, updated_at = now()`, + [account, Number(actionId), blockNumber] + ) +} + +async function seeded (db, account, row, blockNumber) { + const rejectedAt = Number(row.last_rejected_at) > 0 ? toUTC(new Date(Number(row.last_rejected_at) * 1000)) : null + await db.instance.none( + `INSERT INTO member_action_claims (account, action_id, pending, approved, last_rejected_at, updated_block) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (account, action_id) DO UPDATE + SET pending = $3, approved = $4, last_rejected_at = $5, updated_block = $6, updated_at = now()`, + [account, Number(row.action_id), Number(row.pending), Number(row.approved), rejectedAt, blockNumber] + ) +} + +module.exports = { claimOpened, claimClosed, rewarded, seeded } diff --git a/src/config/lane.js b/src/config/lane.js new file mode 100644 index 0000000..b65c3a3 --- /dev/null +++ b/src/config/lane.js @@ -0,0 +1,39 @@ +// Claim-limits lane config: syncs a private lane nodeos (contracts repo `make lane-node-fresh`, +// port 8899) into an isolated `cambiatus_lane_cpa` Postgres DB. Never the shared chain or DB. +// Run with: NODE_ENV=lane node src/app.js +console.log('Loaded Lane configs') + +const forbidden = ['cambiatus_local', 'cambiatus_dev', 'cambiatus_mirror', 'cambiatus_review'] +const url = process.env.CHAIN_URL || 'http://127.0.0.1:8899' +if (url.includes(':8888') || forbidden.includes(process.env.PGDATABASE || '')) { + throw new Error('lane config refuses the shared chain (:8888) and the shared DBs') +} + +module.exports = { + blockchain: { + contract: { + token: 'cambiatus.tk', + community: 'cambiatus.cm', + escrow: 'cambiatus.es' + }, + initialBlock: 1, + url + }, + db: { + // No password key: node-pg must NOT send an empty-string password or Postgres.app + // rejects it under trust auth ("failed to verify trust authentication"). + user: 'postgres', + host: 'localhost', + port: 5432, + database: process.env.PGDATABASE || 'cambiatus_lane_cpa', + schema: 'public' + }, + http: { + port: Number(process.env.HEALTH_PORT || 3011) + }, + sentry: { + dsn: '', + environment: 'lane', + attachStacktrace: true + } +} diff --git a/src/updaters.js b/src/updaters.js index e1d2f74..efd0c3d 100644 --- a/src/updaters.js +++ b/src/updaters.js @@ -12,7 +12,9 @@ const { verifyClaim, claimAction, upsertRole, - assignRole + assignRole, + setActLimit, + seedMbrClaim } = require('./updaters/community.js') const { createToken, @@ -137,6 +139,14 @@ const updaters = [ actionType: `${config.blockchain.contract.community}::assignroles`, updater: assignRole }, + { + actionType: `${config.blockchain.contract.community}::setactlimit`, + updater: setActLimit + }, + { + actionType: `${config.blockchain.contract.community}::seedmbrclm`, + updater: seedMbrClaim + }, // ======== Token { actionType: `${config.blockchain.contract.token}::create`, diff --git a/src/updaters/community.js b/src/updaters/community.js index 2eee973..4d918f0 100644 --- a/src/updaters/community.js +++ b/src/updaters/community.js @@ -6,6 +6,7 @@ const { const config = require(`../config/${process.env.NODE_ENV || 'dev'}`) const { resolveClaimId, resolveCreatedActionId, resolveCreatedObjectiveId, ResolveError } = require('../chain') const { toUTC } = require('../dates') +const claimMirror = require('../claim_mirror') // S5a-es (plan §9): an Elm purchase of a batch offer takes its units from the live batch // through the backend's frozen DB function. Transient database errors are retried; anything @@ -706,6 +707,8 @@ async function reward (db, payload, blockInfo, context) { }) if (Number(existing) > 0) return + await claimMirror.rewarded(db, payload.data.receiver, payload.data.action_id, blockInfo.blockNumber) + // Collect the action return db.actions .findOne(payload.data.action_id) @@ -826,6 +829,7 @@ async function claimAction (db, payload, blockInfo, context) { await db.claims .insert(data) .catch(e => logError('Something went wrong while inserting a claim', e)) + await claimMirror.claimOpened(db, payload.data.maker, payload.data.action_id, blockInfo.blockNumber) } async function verifyClaim (db, payload, blockInfo, context) { @@ -911,6 +915,13 @@ async function verifyClaim (db, payload, blockInfo, context) { } await db.claims.update(claim.id, { status }) + + // Mirror the contract's mbrclaim close. Only the first pending → final transition + // counts; on a replay claim.status is already final and this is skipped. Not gated on + // usages: the contract counts every action. + if (claim.status === 'pending' && status !== 'pending') { + await claimMirror.claimClosed(db, claim.claimer_id, claim.action_id, status === 'approved', blockInfo.timestamp, blockInfo.blockNumber) + } console.log(`Cambiatus >>> Claim Verification: Status Updated to: ${status}`) // Decrement usages_left exactly once, on the first pending -> resolved transition. On a @@ -949,6 +960,30 @@ async function upsertRole (db, payload, blockInfo, _context) { } } +async function setActLimit (db, payload, blockInfo, _context) { + console.log('Cambiatus >>> Set action claim limit', blockInfo.blockNumber) + const actionId = Number(payload.data.action_id) + const max = Number(payload.data.max_per_account) + + const action = await db.actions.findOne({ id: actionId }) + if (action == null) { + // The create's id resolution failed earlier; writing now would guess. Skip + page. + throw new ResolveError(`setactlimit: action ${actionId} not indexed (block ${blockInfo.blockNumber})`) + } + + const data = max === 0 + ? { max_claims_per_account: null, reclaim_cooldown_seconds: null } + : { max_claims_per_account: max, reclaim_cooldown_seconds: Number(payload.data.reclaim_cooldown) } + await db.actions.update({ id: actionId }, data) +} + +async function seedMbrClaim (db, payload, blockInfo, _context) { + console.log('Cambiatus >>> Seed member claim counts', blockInfo.blockNumber) + for (const row of payload.data.rows) { + await claimMirror.seeded(db, payload.data.member, row, blockInfo.blockNumber) + } +} + async function assignRole (db, payload, blockInfo, _context) { console.log('Cambiatus >>> Assign Role', blockInfo.blockNumber) @@ -1024,5 +1059,7 @@ module.exports = { verifyClaim, claimAction, upsertRole, - assignRole + assignRole, + setActLimit, + seedMbrClaim }